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:
parent
65cdc45ed9
commit
db0a7ffd8b
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
|
|
|
|||
|
|
@ -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, ¶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<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, ¶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<Value> {
|
||||
let job = save_job(s, plan)?;
|
||||
let out = job.write(write)?;
|
||||
job.finish(s.doc_mut()?);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<_>>(),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¶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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue