M4.36: Background Save and Export write a snapshot off the UI thread with status bar progress

- With Preferences > File Handling > Enable Background Save / Export on, File >
  Save, Save As, Save a Copy and exports encode and write a snapshot of the
  document on a worker thread; the call returns at once ({path, background}).
- The status bar shows a spinner and what is being written; ui.inspect lists
  the running jobs under `background`.
- The document takes the path and counts as saved as of the snapshot once the
  file is written: edits made meanwhile keep it modified.
- Quit and Close wait for running saves; the Save Changes dialog saves at once.
- The host gives a thread-safe writer (Services::write_shared, atomic on the
  desktop); without one (web, tests) everything runs at once as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 10:56:08 +07:00
parent 65cdc45ed9
commit db0a7ffd8b
14 changed files with 590 additions and 94 deletions

View File

@ -138,6 +138,11 @@ fn reveal_command(path: &str) -> std::process::Command {
c
}
/// Write a file the safe way: a failed write keeps the old file ([`fileio::write_atomic`]).
fn write_file(path: &str, bytes: &[u8]) -> Result<(), String> {
fileio::write_atomic(std::path::Path::new(path), bytes).map_err(|e| e.to_string())
}
fn services() -> Services {
Services {
pick_open: Some(Box::new(|pick: &FilePick| file_dialog(pick).pick_file().map(|p| p.to_string_lossy().to_string()))),
@ -158,7 +163,9 @@ fn services() -> Services {
file_dialog(pick).save_file().map(|p| p.to_string_lossy().to_string())
})),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| fileio::write_atomic(std::path::Path::new(p), b).map_err(|e| e.to_string()))),
write: Some(Box::new(write_file)),
// Background Save and Export write from a worker thread.
write_shared: Some(std::sync::Arc::new(write_file)),
// Every format Copy offers and Paste reads (menu-bar Paste never sees egui's Paste event).
system_clipboard: Some(clipboard::system_clipboard()),
// Help → Discord / website / GitHub, the Discord button, About and Home links.

View File

@ -41,7 +41,9 @@ pub(crate) use load::source;
pub use load::{Loaded, RasterImage, detect, file_name, load, load_with, open_bytes, open_bytes_with, open_template, raster_image};
pub use native::with_compression_pref;
pub use pdfimport::{LoadOptions, page_document};
pub use save::{SAVE_FORMATS, SaveMode, SavePlan, save_filters, save_format, save_plan, save_with, stamp_save_dates, templates_folder};
pub use save::{
SAVE_FORMATS, SaveJob, SaveMode, SavePlan, save_filters, save_format, save_job, save_plan, save_with, stamp_save_dates, templates_folder,
};
pub use svg::options_map as svg_options;
/// Atomic file writes (a temporary file renamed over the target: a failed write never damages the
/// file it replaces), for the apps' own writers too.

View File

@ -39,13 +39,21 @@ struct NativeOptions {
preview: bool,
}
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
/// The options in `p` for saving `doc`, checked (the preview is left to [`encode`]) → them and
/// whether to embed a preview.
pub(super) fn save_options(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<(SaveOptions, bool)> {
let o: NativeOptions = super::encode::options(f, p)?;
let mut so = SaveOptions::for_doc(doc);
so.compress = o.compress.unwrap_or(false);
so.version = o.version.unwrap_or(vectorcraft_format::VERSION);
if o.preview {
so.check().map_err(|e| bad(cmd, e.to_string()))?;
Ok((so, o.preview))
}
/// The `.vectorcraft` file of `doc` with the options in `p`.
pub(super) fn encode(cmd: &str, f: &Format, doc: &Document, p: &Value) -> Result<Vec<u8>> {
let (mut so, preview) = save_options(cmd, f, doc, p)?;
if preview {
so.preview = preview_png(doc)?;
}
vectorcraft_format::save_with(doc, &so).map_err(|e| bad(cmd, e.to_string()))

View File

@ -6,7 +6,7 @@
//! the caller's writer (the file system here; a save panel or a browser download in the apps) and
//! makes the file the document's own (path, format, options, saved state) for Save and Save As.
use std::borrow::Cow;
use std::sync::Arc;
use serde_json::{Map, Value, json};
use vectorcraft_doc::Document;
@ -206,13 +206,13 @@ fn is_svg(f: &Format) -> bool {
}
/// The document as written: native files carry the view to reopen at.
fn doc_to_save<'a>(st: &'a DocState, f: &Format) -> Cow<'a, Document> {
fn doc_to_save(st: &DocState, f: &Format) -> Arc<Document> {
if is_native(f) && st.doc.last_view != st.view {
let mut d = (*st.doc).clone();
d.last_view = st.view.clone();
Cow::Owned(d)
Arc::new(d)
} else {
Cow::Borrowed(&st.doc)
st.doc.clone()
}
}
@ -226,12 +226,23 @@ fn fidelity_warning(f: &Format) -> Option<String> {
})
}
/// Encode `plan` and write it with `write` (the file, then the images an SVG links to, beside it).
/// Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?: [{name,
/// dataBase64}]}` and the document is unchanged; with one → `{path, format, bytes, warnings,
/// linked?: [path…]}`, and Save or Save As (except to a template) make the file the document's
/// own: path, title, format, options, saved state.
pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
/// A save of the active document, snapshotted and ready to encode and write anywhere (Background
/// Save does both on a worker thread): [`save_job`], then [`SaveJob::write`], then
/// [`SaveJob::finish`]. Cheap to clone (the documents are shared).
#[derive(Clone)]
pub struct SaveJob {
plan: SavePlan,
/// The document as written (with the view to reopen at, links relative to the file).
doc: Arc<Document>,
/// The encoder's params.
params: Value,
/// The document when the job was made: what counts as saved once the file is written.
snapshot: Arc<Document>,
}
/// Snapshot the active document for `plan` (stamping File Info's dates when the file becomes the
/// document's own). Bad options fail here, before anything is encoded.
pub fn save_job(s: &mut Session, plan: SavePlan) -> Result<SaveJob> {
let cmd = plan.mode.command();
let retargets = plan.retargets();
// The web build, without a clock, leaves the dates unless given one.
@ -241,10 +252,11 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
{
stamp_save_dates(s.doc_mut()?, at);
}
let own = doc_to_save(s.doc()?, plan.format);
let st = s.doc()?;
let own = doc_to_save(st, plan.format);
// A native file records its links' paths relative to where it is written.
let relative = plan.path.as_deref().filter(|_| is_native(plan.format)).and_then(|p| crate::cmd::links::with_relative_paths(&own, p));
let doc: &Document = relative.as_ref().unwrap_or(&own);
let doc = relative.map_or(own, Arc::new);
let mut params = plan.options.clone();
if is_svg(plan.format) {
// A save keeps hidden layers (not displayed) unless told otherwise; exports leave them out.
@ -254,51 +266,80 @@ pub fn save_with(s: &mut Session, plan: SavePlan, mut write: impl FnMut(&str, &[
if matches!(plan.format.id, "vectorcraft" | "template") {
// Not remembered with the options: the preference decides each time it isn't given.
params = with_compression_pref(&s.prefs, &params);
super::native::save_options(cmd, plan.format, &doc, &params)?;
}
let params = super::pdf::expand_preset(s, cmd, &params)?;
let mut enc = if plan.format.id == "ai" {
let mut params = super::pdf::expand_preset(s, cmd, &params)?.into_owned();
if let (Some(o), "ai") = (params.as_object_mut(), plan.format.id) {
// A PDF of every artboard with the PDF options, always carrying the native document.
let mut q = params.into_owned();
if let Some(o) = q.as_object_mut() {
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
o.extend([("preserveEditing".into(), json!(true)), ("range".into(), json!("all"))]);
}
Ok(SaveJob { plan, doc, params, snapshot: s.doc()?.doc.clone() })
}
impl SaveJob {
pub fn plan(&self) -> &SavePlan {
&self.plan
}
/// Encode the file and write it with `write` (the file, then the images an SVG links to,
/// beside it). Without a path → `{dataBase64, bytes, format, name, folder?, warnings, linked?:
/// [{name, dataBase64}]}`; with one → `{path, format, bytes, warnings, linked?: [path…]}`.
pub fn write(&self, mut write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let (cmd, plan, doc) = (self.plan.mode.command(), &self.plan, &*self.doc);
let mut enc = if plan.format.id == "ai" {
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &self.params)?;
Encoded { warnings, ..Encoded::one(bytes) }
} else {
encode_all(doc, plan.format.id, &self.params)?
};
if enc.files.len() != 1 {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
let (bytes, warnings) = super::pdf::encode(cmd, &doc.without_edit_modes(), &q)?;
Encoded { warnings, ..Encoded::one(bytes) }
} else {
encode_all(doc, plan.format.id, &params)?
};
if enc.files.len() != 1 {
return Err(bad(cmd, "Save writes one artboard: name one, or export several with document.export"));
}
// What the format loses first, then the encoder's own notes (PDF options not applied yet…).
let mut warnings = std::mem::take(&mut enc.warnings);
warnings.splice(0..0, fidelity_warning(plan.format));
let extra = json!({ "format": plan.format.id, "warnings": warnings });
let Some(path) = plan.path else {
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
out["name"] = json!(plan.name);
if let Some(folder) = plan.folder {
out["folder"] = json!(folder);
// What the format loses first, then the encoder's own notes (PDF options not applied yet…).
let mut warnings = std::mem::take(&mut enc.warnings);
warnings.splice(0..0, fidelity_warning(plan.format));
let extra = json!({ "format": plan.format.id, "warnings": warnings });
let Some(path) = &plan.path else {
let mut out = write_encoded(None, &plan.name, doc, &enc, extra)?;
out["name"] = json!(plan.name);
if let Some(folder) = &plan.folder {
out["folder"] = json!(folder);
}
return Ok(out);
};
let files = enc.named(doc, path);
for (p, bytes) in &files {
write(p, bytes)?;
}
return Ok(out);
};
let files = enc.named(doc, &path);
for (p, bytes) in &files {
write(p, bytes)?;
let bytes = files.first().map_or(0, |f| f.1.len());
let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra);
if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
}
Ok(out)
}
let bytes = files.first().map_or(0, |f| f.1.len());
let mut out = super::merge(json!({ "path": path, "bytes": bytes }), extra);
if let Some(linked) = files.get(1..).filter(|l| !l.is_empty()) {
out["linked"] = linked.iter().map(|(p, _)| json!(p)).collect();
}
if retargets {
let st = s.doc_mut()?;
st.path = Some(path.clone());
st.format = plan.format.id;
st.save_options = plan.options;
st.converted = false;
st.mark_saved();
/// Once written: Save and Save As (except to a template) make the file `st`'s own: path,
/// title, format, options, and the snapshot as the saved state (edits made since the job was
/// made keep the document modified).
pub fn finish(self, st: &mut DocState) {
let retargets = self.plan.retargets();
if let (true, Some(path)) = (retargets, self.plan.path) {
st.path = Some(path);
st.format = self.plan.format.id;
st.save_options = self.plan.options;
st.converted = false;
st.mark_saved_as(&self.snapshot);
}
}
}
/// Encode `plan` and write it with `write`, then make the file the document's own (see
/// [`SaveJob`]). Without a path the document is unchanged.
pub fn save_with(s: &mut Session, plan: SavePlan, write: impl FnMut(&str, &[u8]) -> Result<()>) -> Result<Value> {
let job = save_job(s, plan)?;
let out = job.write(write)?;
job.finish(s.doc_mut()?);
Ok(out)
}

View File

@ -163,6 +163,11 @@ impl DocState {
pub fn mark_saved(&mut self) {
self.saved_doc = self.doc.clone();
}
/// Record `snapshot` (the document as a background save took it) as saved: edits made since
/// keep the document modified.
pub fn mark_saved_as(&mut self, snapshot: &Arc<Document>) {
self.saved_doc = snapshot.clone();
}
pub fn title(&self) -> String {
let name = self
.path
@ -625,6 +630,10 @@ impl Session {
pub fn documents(&self) -> &[DocState] {
&self.docs
}
/// The open document with [`DocState::uid`] `uid` (it may have closed since it was looked up).
pub fn document_mut(&mut self, uid: u64) -> Option<&mut DocState> {
self.docs.iter_mut().find(|d| d.uid == uid)
}
pub fn active_index(&self) -> Option<usize> {
self.active
}

View File

@ -117,6 +117,18 @@ impl Default for SaveOptions {
}
impl SaveOptions {
/// Can a file be written this way (a version [`save_with`] writes, compressed only for readers
/// that open compressed files)?
pub fn check(&self) -> Result<(), FormatError> {
if !(MIN_VERSION..=VERSION).contains(&self.version) {
return Err(FormatError::BadVersion(self.version));
}
if self.compress && self.version < COMPRESSED_SINCE {
return Err(FormatError::CompressedTooOld(self.version));
}
Ok(())
}
/// The defaults for saving `doc` to disk: pretty when small (see [`PRETTY_MAX_OBJECTS`]).
pub fn for_doc(doc: &Document) -> Self {
let objects: usize = doc.layers.iter().map(|l| l.count()).sum();
@ -142,12 +154,7 @@ pub fn save_file(doc: &Document) -> Vec<u8> {
/// Serialize a document as `o` says: the version, compression, layout and preview. Editing-mode
/// working copies and images nothing uses are left out.
pub fn save_with(doc: &Document, o: &SaveOptions) -> Result<Vec<u8>, FormatError> {
if !(MIN_VERSION..=VERSION).contains(&o.version) {
return Err(FormatError::BadVersion(o.version));
}
if o.compress && o.version < COMPRESSED_SINCE {
return Err(FormatError::CompressedTooOld(o.version));
}
o.check()?;
let pretty = o.pretty && !o.compress;
let mut d = doc.without_edit_modes().into_owned();
let linked = d.linked_only_images();

View File

@ -0,0 +1,160 @@
//! 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::mpsc::{Receiver, Sender, channel};
use serde_json::{Value, json};
use crate::VectorcraftApp;
/// Writes a file; callable from any thread.
pub type SharedWriteFn = std::sync::Arc<dyn Fn(&str, &[u8]) -> 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,
}
/// A job in progress.
pub struct Job {
id: u64,
/// What the status bar shows ("Saving Poster.vectorcraft").
pub label: String,
/// The document the job reads (`DocState::uid`), if any.
pub doc: Option<u64>,
then: Then,
}
/// 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 }) = tasks.recv() {
let r = 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))
}
}
/// 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`).
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> {
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 }) {
Ok(()) => {
let bg = &mut app.background;
bg.next_id += 1;
bg.jobs.push(Job { id, label: label.clone(), doc, then: Box::new(then) });
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) {
while !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));
}
}

View File

@ -383,7 +383,13 @@ pub fn status_bar(app: &mut VectorcraftApp, ui: &mut Ui) {
egui::RichText::new(format!("X: {} Y: {}", u.readout(p.x), u.readout(p.y))).font(theme::mono(10.5)).color(t.text_dim),
);
}
if !app.ui.status.is_empty() {
// Background saves and exports in progress, else the last message.
if let Some(job) = app.background.jobs.first() {
let more = app.background.jobs.len() - 1;
let text = if more > 0 { format!("{}… (+{more})", job.label) } else { format!("{}…", job.label) };
ui.label(egui::RichText::new(text).size(11.0).color(t.text));
ui.add(egui::Spinner::new().size(12.0).color(t.accent));
} else if !app.ui.status.is_empty() {
ui.label(egui::RichText::new(&app.ui.status).size(11.0).color(t.text));
}
});

View File

@ -82,6 +82,8 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
"documents": app.session.documents().iter().map(|d| json!({"title": d.title(), "dirty": d.is_dirty()})).collect::<Vec<_>>(),
"activeDocument": app.session.active_index(),
"perf": {"frameMs": app.perf.frame_ms, "renderMs": app.perf.render_ms, "fps": app.perf.fps},
// Saves and exports still being written in the background (Background Save / Export).
"background": app.background.jobs.iter().map(|j| j.label.as_str()).collect::<Vec<_>>(),
})
}

View File

@ -6,6 +6,7 @@ use vectorcraft_doc::SavedView;
use vectorcraft_engine::EngineError;
use vectorcraft_engine::cmd::fileio::{self, Format, SAVE_FORMATS, SaveMode, SavePlan};
use crate::background::{self, Writer};
use crate::dialogs::svg_options;
use crate::state::Dialog;
use crate::{FilePick, Services, VectorcraftApp, dialogs};
@ -101,7 +102,7 @@ pub fn new_from_template(app: &mut VectorcraftApp, path: Option<String>) -> Resu
}
/// Web: download `bytes` under `path`'s file name; desktop: write them to `path`.
fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
pub(crate) fn write_to(services: &mut Services, path: &str, bytes: &[u8]) -> Result<(), String> {
if let Some(dl) = services.download.as_mut() {
dl(&fileio::file_name(path), bytes);
return Ok(());
@ -161,10 +162,10 @@ fn with_save_extension(path: &str, f: &Format) -> String {
/// Write every file of an export (one per artboard, linked images) for the destination `path`.
/// → the export's own files (without the linked images).
fn write_encoded(app: &mut VectorcraftApp, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result<Vec<String>, String> {
let named = enc.named(doc, path);
for (p, bytes) in &named {
write_to(&mut app.services, p, bytes)?;
write(p, bytes)?;
}
Ok(named.into_iter().take(enc.files.len()).map(|(p, _)| p).collect())
}
@ -274,16 +275,43 @@ fn report_saved(app: &mut VectorcraftApp, path: &str, r: &Value) {
}
/// Write a planned save through the services and report it in the status bar.
/// With Background Save on, the file is encoded and written from a snapshot off the UI thread
/// ([`background`]): this returns `{path, background: true, status}` at once, and the document
/// takes the file (and counts as saved, as of the snapshot) once it is written.
fn write_plan(app: &mut VectorcraftApp, plan: SavePlan) -> Result<Value, String> {
let retargets = plan.retargets();
let services = &mut app.services;
let r = fileio::save_with(&mut app.session, plan, |path, bytes| write_to(services, path, bytes).map_err(EngineError::Other))
.map_err(|e| e.to_string())?;
let path = r["path"].as_str().unwrap_or_default().to_string();
if retargets {
note_recent(app, &path);
let uid = app.session.active().ok_or("no document")?.uid;
// Saves of one document finish in order.
if app.background.busy_with(uid) {
background::wait_all(app);
}
let job = fileio::save_job(&mut app.session, plan).map_err(|e| e.to_string())?;
let (retargets, target) = (job.plan().retargets(), job.plan().path.clone());
let label = match &target {
Some(path) => format!("Saving {}", fileio::file_name(path)),
None => "Saving".into(),
};
// Without a path the bytes come back to the caller: nothing to do in the background.
let enabled = app.session.prefs.background_save && target.is_some();
// The work writes the file; the copy makes it the document's own afterwards.
let done = job.clone();
let work = move |write: Writer| job.write(|p, b| write(p, b).map_err(EngineError::Other)).map_err(|e| e.to_string());
let then = move |app: &mut VectorcraftApp, r: Result<Value, String>| {
let r = r?;
// The document may have closed meanwhile: the file is written all the same.
if let Some(st) = app.session.document_mut(uid) {
done.finish(st);
}
let path = r["path"].as_str().unwrap_or_default().to_string();
if retargets {
note_recent(app, &path);
}
report_saved(app, &path, &r);
Ok(r)
};
let mut r = background::run(app, enabled, label, Some(uid), work, then)?;
if let Some(path) = target.filter(|_| r.get("path").is_none()) {
r["path"] = json!(path);
}
report_saved(app, &path, &r);
Ok(r)
}
@ -335,21 +363,36 @@ pub fn export(app: &mut VectorcraftApp, format: Option<&str>, path: Option<Strin
Some(z) if f.id == "svg" && z.id == "svgz" => z,
_ => f,
};
let enc = fileio::encode_all(&doc, f.id, params).map_err(|e| e.to_string())?;
let files = write_encoded(app, &doc, &path, &enc)?;
let what = match files.as_slice() {
[one] => one.clone(),
_ => format!("{} files", files.len()),
// Background Export: encoded and written from this snapshot while editing goes on.
let label = format!("Exporting {}", fileio::file_name(&path));
let (params, target) = (params.clone(), path.clone());
let work = move |write: Writer| {
let enc = fileio::encode_all(&doc, f.id, &params).map_err(|e| e.to_string())?;
let files = write_encoded(write, &doc, &path, &enc)?;
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
if files.len() > 1 {
out["files"] = json!(files);
}
Ok(out)
};
app.status(match enc.warnings.first() {
Some(first) => format!("Exported {what} with {} note(s): {first}", enc.warnings.len()),
None => format!("Exported {what}"),
});
let mut out = json!({ "path": files.first().unwrap_or(&path), "warnings": enc.warnings });
if files.len() > 1 {
out["files"] = json!(files);
let enabled = app.session.prefs.background_export;
let mut r = background::run(app, enabled, label, None, work, |app, r| {
let r = r?;
let what = match r["files"].as_array() {
Some(files) => format!("{} files", files.len()),
None => r["path"].as_str().unwrap_or_default().to_string(),
};
let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(Value::as_str).collect()).unwrap_or_default();
app.status(match warnings.first() {
Some(first) => format!("Exported {what} with {} note(s): {first}", warnings.len()),
None => format!("Exported {what}"),
});
Ok(r)
})?;
if r.get("path").is_none() {
r["path"] = json!(target);
}
Ok(out)
Ok(r)
}
/// Run an engine command that returns `{dataBase64}` and write the bytes to a picked path

View File

@ -5,6 +5,7 @@
//! shortcuts, the ⌘K palette and the control channel ([`control`]).
#![forbid(unsafe_code)]
pub mod background;
mod brand;
pub mod canvas;
pub mod chrome;
@ -37,6 +38,8 @@ pub mod workspaces;
#[cfg(test)]
mod tests_aisave;
#[cfg(test)]
mod tests_background;
#[cfg(test)]
mod tests_clipboard;
#[cfg(test)]
mod tests_docsetup;
@ -135,6 +138,9 @@ pub struct Services {
pub system_clipboard: Option<Box<dyn SystemClipboard>>,
/// File → Show in Folder: select a file in the system file manager (desktop).
pub reveal: Option<RevealFn>,
/// Write a file from any thread (desktop): lets Background Save and Export write off the UI
/// thread ([`background`]). Without it they run at once.
pub write_shared: Option<background::SharedWriteFn>,
}
/// Cached canvas raster.
@ -220,6 +226,8 @@ pub struct VectorcraftApp {
system_paste_at: f64,
/// Keyboard pastes of something other than text (see [`shortcuts::PasteChord`]).
pub(crate) paste_chord: shortcuts::PasteChord,
/// Saves and exports running in the background (Preferences → File Handling).
pub background: background::Background,
}
/// Seconds between two looks at the system clipboard for [`VectorcraftApp::system_paste`].
@ -266,6 +274,7 @@ impl VectorcraftApp {
system_paste: false,
system_paste_at: f64::NEG_INFINITY,
paste_chord: Default::default(),
background: Default::default(),
}
}
@ -530,11 +539,20 @@ impl VectorcraftApp {
self.system_paste_at = now;
self.system_paste = self.session.clipboard.is_empty() && self.session.active().is_some() && self.system_clipboard_pasteable();
}
// The window's close button (or the system quitting the app) asks about unsaved documents.
if ctx.input(|i| i.viewport().close_requested()) && unsaved::any_dirty(self) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if let Err(e) = unsaved::close_all(self, "quit") {
self.status(e);
background::poll(self);
if !self.background.jobs.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));
}
// The window's close button (or the system quitting the app) asks about unsaved documents,
// once the saves running in the background are done.
if ctx.input(|i| i.viewport().close_requested()) {
background::wait_all(self);
if unsaved::any_dirty(self) {
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
if let Err(e) = unsaved::close_all(self, "quit") {
self.status(e);
}
}
}
shortcut_editor::sync(&self.ui);

View File

@ -0,0 +1,182 @@
//! Background Save and Background Export: the file is written from a snapshot off the UI thread,
//! and the document counts as saved as of that snapshot.
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::{Appearance, Document, Node};
use vectorcraft_engine::Session;
use vectorcraft_geom::{Rect, shapes};
use crate::{Services, VectorcraftApp, background, io};
type Files = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Holds the background writer until opened.
#[derive(Clone, Default)]
struct Gate(Arc<AtomicBool>);
impl Gate {
fn open(&self) {
self.0.store(true, Ordering::SeqCst);
}
fn wait(&self) {
while !self.0.load(Ordering::SeqCst) {
std::thread::sleep(std::time::Duration::from_millis(1));
}
}
}
/// An app whose thread-safe writer records files once `gate` opens.
fn app(gate: Gate) -> (VectorcraftApp, Files) {
let files = Files::default();
let f = files.clone();
let services = Services {
write: Some(Box::new(|_: &str, _: &[u8]| Err("the UI thread must not write here".into()))),
write_shared: Some(Arc::new(move |p: &str, b: &[u8]| {
gate.wait();
f.lock().unwrap().push((p.to_string(), b.to_vec()));
Ok(())
})),
..Default::default()
};
(VectorcraftApp::new(Session::new(), services), files)
}
/// A document of `n` paths.
fn paths(n: usize) -> Document {
let mut d = Document::new(1000.0, 1000.0);
let layer = d.layers[0].id;
for i in 0..n {
let id = d.alloc_id();
let (x, y) = ((i % 250) as f64 * 4.0, (i / 250) as f64 * 4.0);
d.insert(Some(layer), i, Node::path(id, shapes::ellipse(Rect::new(x, y, x + 3.0, y + 3.0)), Appearance::default_art())).unwrap();
}
d
}
fn written(files: &Files) -> Vec<String> {
files.lock().unwrap().iter().map(|(p, _)| p.clone()).collect()
}
#[test]
fn a_50k_path_save_returns_at_once_and_the_file_appears_later() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.session.add_document(paths(50_000), None);
let r = app.run("file.save", json!({"path": "/docs/big.vectorcraft"})).unwrap();
assert_eq!((r["background"].as_bool(), r["path"].as_str()), (Some(true), Some("/docs/big.vectorcraft")));
assert!(written(&files).is_empty(), "nothing written yet");
assert_eq!(app.background.jobs.len(), 1);
assert!(app.ui.status.starts_with("Saving big.vectorcraft"));
// The document is saved once the file is written.
assert!(app.session.doc().unwrap().is_dirty() && app.session.doc().unwrap().path.is_none());
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/docs/big.vectorcraft"]);
let st = app.session.doc().unwrap();
assert!(!st.is_dirty());
assert_eq!(st.path.as_deref(), Some("/docs/big.vectorcraft"));
assert_eq!(app.ui.status, "Saved /docs/big.vectorcraft");
let bytes = files.lock().unwrap()[0].1.clone();
assert_eq!(vectorcraft_format::load(&bytes).unwrap().node_count(), 50_001);
}
#[test]
fn the_dirty_state_follows_the_snapshot() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap();
app.run("file.save", json!({"path": "/docs/a.vectorcraft"})).unwrap();
// Edited while the file is written: still modified afterwards.
app.run("shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap();
gate.open();
background::wait_all(&mut app);
assert!(app.session.doc().unwrap().is_dirty(), "the edit came after the snapshot");
let saved = vectorcraft_format::load(&files.lock().unwrap()[0].1).unwrap();
assert_eq!(saved.node_count(), 2, "the file holds the snapshot");
// Undoing back to the snapshot is clean again.
app.run("edit.undo", json!({})).unwrap();
assert!(!app.session.doc().unwrap().is_dirty());
}
#[test]
fn exports_run_in_the_background_and_the_preference_turns_both_off() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
let r = io::export(&mut app, Some("svg"), Some("/out/a.svg".into()), &json!({})).unwrap();
assert_eq!((r["background"].as_bool(), r["path"].as_str()), (Some(true), Some("/out/a.svg")));
gate.open();
background::wait_all(&mut app);
assert_eq!(written(&files), ["/out/a.svg"]);
assert_eq!(app.ui.status, "Exported /out/a.svg");
// Preferences off: at once, through the ordinary writer (which this app refuses).
app.session.execute("prefs.set", &json!({"values": {"backgroundSave": false, "backgroundExport": false}})).unwrap();
assert!(io::export(&mut app, Some("svg"), Some("/out/b.svg".into()), &json!({})).is_err());
assert!(app.run("file.save", json!({"path": "/docs/x.vectorcraft"})).is_err());
assert!(app.background.jobs.is_empty());
}
#[test]
fn quitting_waits_for_background_saves() {
let gate = Gate::default();
let (mut app, files) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("file.save", json!({"path": "/docs/q.vectorcraft"})).unwrap();
let opener = gate.clone();
let release = std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(50));
opener.open();
});
// Without waiting, the document would still count as modified and Quit would ask about it.
let r: Value = app.run("app.quit", json!({})).unwrap();
release.join().unwrap();
assert!(r.is_null(), "{r}");
assert_eq!(app.ui.status, "quit");
assert_eq!(written(&files), ["/docs/q.vectorcraft"]);
}
/// The status bar's texts in one headless frame.
fn status_bar_texts(app: &mut VectorcraftApp) -> Vec<String> {
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
match s {
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
_ => {}
}
}
let ctx = egui::Context::default();
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::chrome::status_bar(app, ui));
out.textures_delta.clear();
let mut v = vec![];
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
v
}
#[test]
fn the_status_bar_shows_progress() {
let gate = Gate::default();
let (mut app, _) = app(gate.clone());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
app.run("file.save", json!({"path": "/docs/p.vectorcraft"})).unwrap();
io::export(&mut app, Some("svg"), Some("/out/p.svg".into()), &json!({})).unwrap();
let busy = status_bar_texts(&mut app);
assert!(busy.iter().any(|t| t == "Saving p.vectorcraft… (+1)"), "{busy:?}");
gate.open();
background::wait_all(&mut app);
let done = status_bar_texts(&mut app);
assert!(done.iter().any(|t| t == "Exported /out/p.svg") && !done.iter().any(|t| t.contains('…')), "{done:?}");
}
#[test]
fn bad_options_fail_before_going_to_the_background() {
let (mut app, files) = app(Gate::default());
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
for bad in [json!({"path": "/docs/v.vectorcraft", "version": 9}), json!({"path": "/docs/v.vectorcraft", "version": 1, "compress": true})] {
assert!(app.run("file.save", bad.clone()).is_err(), "{bad}");
}
assert!(app.background.jobs.is_empty() && written(&files).is_empty());
}

View File

@ -18,6 +18,8 @@ pub const KIND: &str = "saveChanges";
/// File → Close (or a tab's ×) for document `i`.
pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
// A save still running decides whether there is anything left to save.
crate::background::wait_all(app);
let dirty = app.session.documents().get(i).ok_or("no such document")?.is_dirty();
if dirty { ask(app, i, "close") } else { close_now(app, i) }
}
@ -25,6 +27,7 @@ pub fn close(app: &mut VectorcraftApp, i: usize) -> Result<Value, String> {
/// File → Close All (`then` = `closeAll`) or Quit (`quit`): ask about the next modified document,
/// or finish once none is left.
pub fn close_all(app: &mut VectorcraftApp, then: &str) -> Result<Value, String> {
crate::background::wait_all(app);
if let Some(i) = app.session.documents().iter().position(|d| d.is_dirty()) {
return ask(app, i, then);
}
@ -71,7 +74,14 @@ pub fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
let i = i.ok_or("no such document")?;
if !d.bool("discard") {
app.session.set_active(i);
io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
let r = io::save(app, vectorcraft_engine::cmd::fileio::SaveMode::Save, &json!({}), false)?;
// Closing needs the file written: wait for a background save, and stop if it failed.
if r["background"] == true {
crate::background::wait_all(app);
if let Some(d) = app.session.documents().get(i).filter(|d| d.is_dirty()) {
return Err(format!("{}: not saved, so not closed", d.title()));
}
}
}
close_now(app, i)?;
match d.str("then").as_str() {

View File

@ -8,7 +8,7 @@
| `engine.execute` | `{command, params}` | run any engine or UI command (see `engine.commands`) |
| `engine.commands` | | every command with label, shortcut, params doc, enablement |
| `document.inspect` | | layer tree, selection, history, paint defaults |
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf |
| `ui.inspect` | | tool, UI state, view, canvas rect, window size, perf, background saves and exports still running |
| `ui.menu.list` / `ui.menu.invoke` | `{command, params}` | the full menu tree / invoke an item |
| `ui.tool.select` / `ui.tool.list` | `{tool}` | |
| `ui.pointer` | `{events:[{kind: down|drag|up|move|doubleclick, x, y, space?: "doc"|"screen", mods?}]}` | drive the active tool exactly like the mouse |
@ -30,6 +30,7 @@
| `app.export` PNG-8 and GIF | `{path?, format: png8 \| gif, colors?: 2–256, reduction?: perceptual \| selective \| adaptive \| web \| blackWhite \| gray, dither?: none \| diffusion \| pattern \| noise, ditherAmount?: 0–100, transparency?, matte?, interlaced?, …raster options}` | a palette of at most `colors` entries (art with that many colours or fewer keeps them exactly); pixels under half opacity become one transparent entry unless `transparency` is false, partly transparent edges are blended over `matte` (`none` keeps their colour). `png8` writes an indexed `.png` (1–8 bits per pixel); Export for Screens rows take both. The `png8Options` / `gifOptions` dialogs have these fields |
| `app.export` Text and WebP | `{path?, format: txt, encoding?: utf8 \| utf16, lineEndings?: lf \| crlf, selectionOnly?}` / `{format: webp, lossless?, quality?}` | Text writes the stories in stacking order (back to front; a thread once; hidden objects and template layers left out), each line ended by `lineEndings`; `utf16` is little-endian with a byte order mark. Export As → Text opens `txtOptions` (fields `path`, `encoding`, `lineEndings`, `selectionOnly`; OK writes the file). WebP is always lossless: `lossless: false` adds a warning |
| `engine.execute` `document.exportForOffice` | `{}` / `{path?, ppi?: 72 \| 150 \| 300, transparent?, artboard?}` | File → Save for Office Documents…: without params opens `saveForOffice` (fields `ppi`, `transparent`, `artboard`; OK picks the file); with params writes the artboard as a PNG (`path`, else a picked file) → `{path}`. Headless it returns `{dataBase64, width, height}` |
| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy` | with the `backgroundSave` / `backgroundExport` preferences on (the default) the desktop app writes the file from a snapshot on a worker thread: the call returns `{path, background: true, status}` at once, `ui.inspect` lists the jobs still running in `background` (labels such as `"Saving a.vectorcraft"`), and the document counts as saved (as of the snapshot) once its file is written. Quit and Close wait for them. With the preferences off (`prefs.set`), saves return once written |
| `ui.dialog.*` on `importPdf` | `{field: "page" \| "allPages" \| "range" \| "cropTo" \| "password", value}` | opening (`app.open`, `file.open`) a PDF or `.ai` with several pages or a password, or placing one (`file.place` without `page`), opens the Import PDF dialog (`mode`: `open` or `place`; `pageCount`). While `__locked` is true, set `password` and `ui.dialog.confirm` to unlock it (a one-page file then opens or is placed). Then set `allPages: false` and `range` ("2-3, 5") to open some pages, or `page` to place one, and `cropTo` (`bounding`, `art`, `crop`, `trim`, `bleed`, `media`); `ui.dialog.confirm` opens or places them (placing keeps the other `file.place` params, such as `link`, `template` and `at`, in `__place`). `document.open {pages, cropTo, password}` and `file.place {page, crop, password}` do the same without the dialog |