From db0a7ffd8b090d876c040b173f31ab2e5ffd2d5e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Mon, 5 Oct 2026 10:56:08 +0700 Subject: [PATCH] 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 --- apps/vectorcraft/src/main.rs | 9 +- crates/engine/src/cmd/fileio/mod.rs | 4 +- crates/engine/src/cmd/fileio/native.rs | 14 +- crates/engine/src/cmd/fileio/save.rs | 143 ++++++++++++------- crates/engine/src/lib.rs | 9 ++ crates/format/src/lib.rs | 19 ++- crates/ui-egui/src/background.rs | 160 ++++++++++++++++++++++ crates/ui-egui/src/chrome.rs | 8 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/io.rs | 91 +++++++++---- crates/ui-egui/src/lib.rs | 28 +++- crates/ui-egui/src/tests_background.rs | 182 +++++++++++++++++++++++++ crates/ui-egui/src/unsaved.rs | 12 +- docs/control-protocol.md | 3 +- 14 files changed, 590 insertions(+), 94 deletions(-) create mode 100644 crates/ui-egui/src/background.rs create mode 100644 crates/ui-egui/src/tests_background.rs diff --git a/apps/vectorcraft/src/main.rs b/apps/vectorcraft/src/main.rs index a01f4bcb..44331764 100644 --- a/apps/vectorcraft/src/main.rs +++ b/apps/vectorcraft/src/main.rs @@ -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. diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index d4438a8a..217c07f0 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -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. diff --git a/crates/engine/src/cmd/fileio/native.rs b/crates/engine/src/cmd/fileio/native.rs index 24c78908..aef6dd8a 100644 --- a/crates/engine/src/cmd/fileio/native.rs +++ b/crates/engine/src/cmd/fileio/native.rs @@ -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> { +/// 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> { + 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())) diff --git a/crates/engine/src/cmd/fileio/save.rs b/crates/engine/src/cmd/fileio/save.rs index 17ed6cf4..7eb186f9 100644 --- a/crates/engine/src/cmd/fileio/save.rs +++ b/crates/engine/src/cmd/fileio/save.rs @@ -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 { 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 { }) } -/// 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 { +/// 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, + /// The encoder's params. + params: Value, + /// The document when the job was made: what counts as saved once the file is written. + snapshot: Arc, +} + +/// 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 { 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, ¶ms); + super::native::save_options(cmd, plan.format, &doc, ¶ms)?; } - let params = super::pdf::expand_preset(s, cmd, ¶ms)?; - let mut enc = if plan.format.id == "ai" { + let mut params = super::pdf::expand_preset(s, cmd, ¶ms)?.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 { + 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, ¶ms)? - }; - 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 { + let job = save_job(s, plan)?; + let out = job.write(write)?; + job.finish(s.doc_mut()?); Ok(out) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 014e0dcf..25f37246 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -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) { + 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 { self.active } diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 8ae37a6b..0fed0cd4 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -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 { /// 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, 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(); diff --git a/crates/ui-egui/src/background.rs b/crates/ui-egui/src/background.rs new file mode 100644 index 00000000..f5d070d8 --- /dev/null +++ b/crates/ui-egui/src/background.rs @@ -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 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 Result + Send>; + +/// Applies a result on the UI thread (document state, status, recent files) → the command result. +type Then = Box) -> Result>; + +/// A job's id and the work's result, back from the worker. +type Done = (u64, Result); + +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, + then: Then, +} + +/// The worker and the jobs it has not finished. +#[derive(Default)] +pub struct Background { + worker: Option<(Sender, Receiver)>, + /// Jobs in the order they were started (the worker runs them in that order). + pub jobs: Vec, + 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> { + if cfg!(target_arch = "wasm32") { + return None; + } + if self.worker.is_none() { + let (tx, tasks) = channel::(); + 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, + work: impl FnOnce(Writer) -> Result + Send + 'static, + then: impl FnOnce(&mut VectorcraftApp, Result) -> Result + 'static, +) -> Result { + 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) { + if let Err(e) = (job.then)(app, r) { + app.status(format!("{} failed: {e}", job.label)); + } +} diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 2fce4876..42fceecb 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -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)); } }); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 2bf3ce89..a2105e72 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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::>(), "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::>(), }) } diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 257721fa..1a95dd61 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -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) -> 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, String> { +fn write_encoded(write: Writer, doc: &vectorcraft_doc::Document, path: &str, enc: &fileio::Encoded) -> Result, 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 { - 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| { + 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 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, ¶ms).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 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 7f6d75bc..3a2a51da 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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>, /// File → Show in Folder: select a file in the system file manager (desktop). pub reveal: Option, + /// 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, } /// 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); diff --git a/crates/ui-egui/src/tests_background.rs b/crates/ui-egui/src/tests_background.rs new file mode 100644 index 00000000..c0b92a49 --- /dev/null +++ b/crates/ui-egui/src/tests_background.rs @@ -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)>>>; + +/// Holds the background writer until opened. +#[derive(Clone, Default)] +struct Gate(Arc); + +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 { + 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 { + fn texts(s: &egui::Shape, out: &mut Vec) { + 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()); +} diff --git a/crates/ui-egui/src/unsaved.rs b/crates/ui-egui/src/unsaved.rs index 7e80a39b..24ddb160 100644 --- a/crates/ui-egui/src/unsaved.rs +++ b/crates/ui-egui/src/unsaved.rs @@ -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 { + // 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 { /// 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 { + 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 { 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() { diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c88aed29..cae57cba 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 |