From ddaca94e5c814621ce9318816b61b1fe57844630 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 22 Oct 2026 09:40:15 -0700 Subject: [PATCH] Export for Screens: files are encoded and written on the background thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export for Screens and the Asset Export panel's Export encoded and wrote every file on the UI thread, so the window stopped responding until the last file was written. For 50 artboards in PNG at 1x, 2x and 3x, PDF and SVG (250 files, 47 MiB), the dialog's OK took a median of 43.8 s in a release build on Windows. With Enable Background Export on (Preferences › File Handling), OK now checks the settings, remembers them and takes a snapshot of the document, and the dialog closes. The thread that writes background saves and exports then encodes the files and writes them into the folder, or one ZIP in it, and the export progress dialog's Cancel stops it before the next file. The same OK takes under 0.001 s, and the files have the same names and bytes. With the preference off, without a folder, on the web and over MCP and scripts (document.exportForScreens and assets.export), the files are made at once with the same reply as before. Both commands report one progress step per file, so the progress dialog shows "File 3 of 250" and MCP progress notifications count the files in place of the encoders' artboards and pages. Docs: docs/target-app-parity.md, docs/control-protocol.md, docs/mcp.md. --- crates/doc/src/cancel.rs | 30 +++ crates/engine/src/cmd/assets.rs | 2 +- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/cmd/fileio/progress.rs | 15 ++ crates/engine/src/cmd/fileio/screens.rs | 113 ++++++++-- crates/engine/src/cmd/fileio/tests_screens.rs | 165 ++++++++++++++ crates/engine/src/lib.rs | 5 + .../ui-egui/src/dialogs/export_for_screens.rs | 2 + crates/ui-egui/src/dialogs/tests_screens.rs | 201 +++++++++++++++++- crates/ui-egui/src/export_progress.rs | 58 ++++- crates/ui-egui/src/i18n/bg.tsv | 1 + crates/ui-egui/src/i18n/cs.tsv | 1 + crates/ui-egui/src/i18n/da.tsv | 1 + crates/ui-egui/src/i18n/de.tsv | 1 + crates/ui-egui/src/i18n/el.tsv | 1 + crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fi.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/hu.tsv | 1 + crates/ui-egui/src/i18n/id.tsv | 1 + crates/ui-egui/src/i18n/it.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/ka.tsv | 1 + crates/ui-egui/src/i18n/ko.tsv | 1 + crates/ui-egui/src/i18n/nl.tsv | 1 + crates/ui-egui/src/i18n/pl.tsv | 1 + crates/ui-egui/src/i18n/pt-br.tsv | 1 + crates/ui-egui/src/i18n/ru.tsv | 1 + crates/ui-egui/src/i18n/sv.tsv | 1 + crates/ui-egui/src/i18n/tr.tsv | 1 + crates/ui-egui/src/i18n/uk.tsv | 1 + crates/ui-egui/src/i18n/vi.tsv | 1 + crates/ui-egui/src/i18n/zh-hans.tsv | 1 + crates/ui-egui/src/i18n/zh-hant.tsv | 1 + crates/ui-egui/src/io.rs | 41 +++- crates/ui-egui/src/menus.rs | 2 +- crates/ui-egui/src/panels/asset_export.rs | 5 +- docs/control-protocol.md | 2 +- docs/mcp.md | 3 +- docs/target-app-parity.md | 1 + 40 files changed, 636 insertions(+), 35 deletions(-) diff --git a/crates/doc/src/cancel.rs b/crates/doc/src/cancel.rs index bff510707..e38b0ab81 100644 --- a/crates/doc/src/cancel.rs +++ b/crates/doc/src/cancel.rs @@ -92,6 +92,15 @@ pub fn check() -> Result<(), Cancelled> { if is_cancelled() { Err(Cancelled) } else { Ok(()) } } +/// Run `f` with this thread's token still in effect and its progress reports dropped: for work +/// that reports its own steps around calls that report theirs (each file of a batch export). +pub fn quiet(f: impl FnOnce() -> T) -> T { + match CURRENT.with(|c| c.borrow().as_ref().map(|s| s.token.clone())) { + Some(token) => scope(&token, None, f), + None => f(), + } +} + /// Report that `done` of `total` steps (0: unknown) are finished. A no-op outside a [`scope`] /// that has a sink. pub fn progress(done: u64, total: u64, what: &str) { @@ -139,4 +148,25 @@ mod tests { progress(2, 3, "after"); assert_eq!(*seen.borrow(), vec![(1, 3, "page".to_string())]); } + + #[test] + fn quiet_work_reports_nothing_and_can_still_be_cancelled() { + let seen = Rc::new(RefCell::new(vec![])); + let sink: ProgressSink = { + let seen = seen.clone(); + Rc::new(move |d, t, w| seen.borrow_mut().push((d, t, w.to_string()))) + }; + let t = CancelToken::new(); + scope(&t, Some(sink), || { + progress(0, 2, "file"); + quiet(|| { + progress(0, 1, "artboard"); + t.cancel(); + assert_eq!(check(), Err(Cancelled)); + }); + progress(1, 2, "file"); + }); + assert_eq!(*seen.borrow(), vec![(0, 2, "file".to_string()), (1, 2, "file".to_string())]); + assert_eq!(quiet(check), Ok(()), "outside a scope nothing is cancelled"); + } } diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index a137271c5..c560a35c8 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -65,7 +65,7 @@ pub fn specs() -> Vec { } /// `e` as an error of command `cmd` (for errors of the export underneath). -fn of_cmd(cmd: &str, e: EngineError) -> EngineError { +pub(crate) fn of_cmd(cmd: &str, e: EngineError) -> EngineError { match e { EngineError::BadParams { msg, .. } => bad(cmd, msg), e => e, diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 77fde102b..c819b9ae6 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -78,7 +78,7 @@ pub use save::{ save_with, stamp_save_dates, template_filters, templates_dialog_folder, templates_folder, }; pub use screens::{ - PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExportPreset, ScreenSize, + PRESET_KEYS as SCREEN_EXPORT_PRESET_KEYS, PRESETS as SCREEN_PRESETS, ScreenExport, ScreenExportPreset, ScreenSize, builtin_presets as screen_export_builtin_presets, first_file as screen_first_file, preset_rows as screen_preset_rows, }; pub(crate) use screens::{ diff --git a/crates/engine/src/cmd/fileio/progress.rs b/crates/engine/src/cmd/fileio/progress.rs index a69cf96a8..d2e039348 100644 --- a/crates/engine/src/cmd/fileio/progress.rs +++ b/crates/engine/src/cmd/fileio/progress.rs @@ -8,6 +8,9 @@ //! renderer stops drawing and keeps no half-drawn pattern tile or effect raster. Encoders that //! make one file in one go report nothing: the frontend shows them as busy and checks //! [`Progress::cancelled`] itself before writing, so a cancelled export never writes a file. +//! Export for Screens ([`super::ScreenExport::write`]) reports one step per file instead, keeps +//! its encoders' reports out, and stops before the next file, so the files it wrote before the +//! cancel stay. use std::rc::Rc; use std::sync::Arc; @@ -21,6 +24,8 @@ pub struct Progress { token: CancelToken, done: AtomicU64, total: AtomicU64, + /// The steps are files (Export for Screens), not artboards. + files: bool, } impl Progress { @@ -28,6 +33,16 @@ impl Progress { Arc::new(Self::default()) } + /// A [`Progress`] whose steps are files: the export of [`super::ScreenExport::write`]. + pub fn of_files() -> Arc { + Arc::new(Self { files: true, ..Self::default() }) + } + + /// Are the steps files rather than artboards? + pub fn counts_files(&self) -> bool { + self.files + } + /// `(steps done, steps in all)`; `total` is 0 while the export can't say (one file made in one go). pub fn counts(&self) -> (u64, u64) { let total = self.total.load(Ordering::Relaxed); diff --git a/crates/engine/src/cmd/fileio/screens.rs b/crates/engine/src/cmd/fileio/screens.rs index 4c3816ebd..9c33d7e24 100644 --- a/crates/engine/src/cmd/fileio/screens.rs +++ b/crates/engine/src/cmd/fileio/screens.rs @@ -26,6 +26,8 @@ use super::{ }; const C: &str = "document.exportForScreens"; +/// The Asset Export panel's export, which exports through [`export`]. +const ASSETS: &str = "assets.export"; /// How big a raster row's files are. #[derive(Clone, Copy, Debug, PartialEq)] @@ -364,9 +366,9 @@ impl ScreenParams { } /// How a batch export names its files and sorts them into folders. -struct Naming<'a> { +struct Naming { /// The `name` pattern, when it holds more than spaces. - pattern: Option<&'a str>, + pattern: Option, /// The `prefix`, through [`file_part`]. prefix: String, /// The document's name, safe as a file name (``, the Full Document's item). @@ -379,9 +381,9 @@ struct Naming<'a> { folders: Folders, } -impl<'a> Naming<'a> { +impl Naming { /// The naming of params `o` for a document titled `title`. - fn of(o: &'a ScreenParams, title: &str) -> Result { + fn of(o: &ScreenParams, title: &str) -> Result { let folders = Folders::of(o)?; // A prefix names files, not folders. let prefix = file_part(o.prefix.as_deref().unwrap_or("")); @@ -395,7 +397,7 @@ impl<'a> Naming<'a> { Some(c) => return Err(bad(C, format!("counterPer `{c}`: item or file"))), }; let filename = safe_name(&super::file_stem(title)).unwrap_or_else(|| "Untitled".into()); - let pattern = o.name.as_deref().filter(|n| !n.trim().is_empty()); + let pattern = o.name.clone().filter(|n| !n.trim().is_empty()); Ok(Self { pattern, prefix, filename, when, counter_start: o.counter_start.unwrap_or(1), per_file, folders }) } @@ -405,7 +407,7 @@ impl<'a> Naming<'a> { let counter = self.counter_start.saturating_add(if self.per_file { files } else { index as u64 }); let ext = r.format.extensions.first().copied().unwrap_or(r.format.id); let prefix = &self.prefix; - let stem = match self.pattern { + let stem = match self.pattern.as_deref() { Some(pattern) => { let parts = NameParts { filename: &self.filename, @@ -507,8 +509,15 @@ impl Output { None } - /// Write `bytes` as `name` (relative) under `folder`, or keep them. - fn put(&mut self, folder: Option<&str>, keep: bool, name: String, bytes: Vec) -> Result<()> { + /// Write `bytes` as `name` (relative) under `folder` with `writer`, or keep them. + fn put( + &mut self, + folder: Option<&str>, + keep: bool, + name: String, + bytes: Vec, + writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>, + ) -> Result<()> { match folder { Some(dir) if !keep => { if let Some((sub, _)) = name.rsplit_once('/') @@ -517,7 +526,9 @@ impl Output { create_dir(&format!("{dir}/{sub}"))?; } let path = format!("{dir}/{name}"); - write_file(&path, &bytes)?; + // A cancel that came while the file was encoded writes nothing more. + vectorcraft_doc::cancel::check()?; + writer(&path, &bytes)?; self.written.push(path); } _ => self.kept.push((name, bytes)), @@ -659,12 +670,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { let rows = rows_of(s, p, o.preset.as_deref())?; o.one_source()?; let naming = Naming::of(&o, &s.doc()?.doc.title)?; - let folder = o.folder.as_deref().filter(|f| !f.is_empty()); let selection = o.selection_mode()?; let selected = if selection.is_some() { selected_artwork(s)? } else { vec![] }; - if let Some(dir) = folder { - create_dir(dir)?; - } let doc = s.doc()?.doc.clone(); // Include Bleed: each artboard grown by the document's bleed. let doc = if o.include_bleed && doc.setup.has_bleed() { @@ -678,12 +685,77 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { }; let crop = rows.iter().any(|r| r.format.id != "pdf"); let items = items(&doc, &o, selection, &selected, &naming.filename, crop, true)?; + // Appearance of Black › Printing / Exporting for RGB raster rows. + let rows = rows.into_iter().map(|r| Row { options: with_black_output(s, &r.options), ..r }).collect(); + let export = ScreenExport { cmd: C, o, rows, naming, items }; + match &mut s.screens_hand_off { + Some(slot) => { + *slot = Some(export); + Ok(Value::Null) + } + None => export.write(&mut |path, bytes| write_file(path, bytes)), + } +} + +/// An export as `document.exportForScreens` and `assets.export` take it from the session: the +/// params, the format rows, the naming, and each item with the document it writes +/// ([`Session::prepare_screen_export`]). [`ScreenExport::write`] encodes and writes its files +/// without the session, on any thread. +pub struct ScreenExport { + /// The command whose errors it gives: `document.exportForScreens` or `assets.export`. + cmd: &'static str, + o: ScreenParams, + rows: Vec, + naming: Naming, + items: Vec, +} + +impl Session { + /// Run `assets.export` when `id` names it, else `document.exportForScreens`, with `params` as + /// a command (checked and journaled as any other; Export for Screens remembers its settings) + /// that encodes and writes nothing → the export it prepared, which [`ScreenExport::write`] + /// encodes and writes (on the desktop with Background Export on, Export for Screens and the + /// Asset Export panel do this on the background thread). + pub fn prepare_screen_export(&mut self, id: &str, params: &Value) -> Result { + let cmd = if id == ASSETS { ASSETS } else { C }; + self.screens_hand_off = Some(None); + let r = self.execute(cmd, params); + let handed = self.screens_hand_off.take().flatten(); + match (r, handed) { + (Err(e), _) => Err(e), + (Ok(_), Some(export)) => Ok(ScreenExport { cmd, ..export }), + // Every run that succeeds hands one over. + (Ok(_), None) => Err(bad(cmd, "nothing to export")), + } + } +} + +impl ScreenExport { + /// Make the export folder, encode the files and write each with `writer` into the folder and + /// its sub-folders, or keep them to zip (the zip written with `writer` into the folder) or to + /// return → the reply of `document.exportForScreens`. It reports one progress step per file + /// ([`vectorcraft_doc::cancel::progress`]), and a cancel stops it before it encodes or writes + /// another file. + pub fn write(self, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + let cmd = self.cmd; + // A refusal names `assets.export` when that command prepared the export, as its reply does. + encode_and_write(self, writer).map_err(|e| if cmd == C { e } else { crate::cmd::assets::of_cmd(cmd, e) }) + } +} + +/// [`ScreenExport::write`]. +fn encode_and_write(export: ScreenExport, writer: &mut dyn FnMut(&str, &[u8]) -> Result<()>) -> Result { + let ScreenExport { o, rows, naming, items, .. } = export; + let folder = o.folder.as_deref().filter(|f| !f.is_empty()); + if let Some(dir) = folder { + create_dir(dir)?; + } let mut out = Output::default(); // The encoders' warnings for every file, each once. let mut warnings: Vec = Vec::new(); - // Appearance of Black › Printing / Exporting for RGB raster rows. let mut write = |doc: &Document, format: &str, options: &Value| -> Result> { - let (bytes, notes) = encode_with_warnings(doc, format, &with_black_output(s, options))?; + // The steps are the files: an encoder's own steps (artboards, pages) are not reported. + let (bytes, notes) = vectorcraft_doc::cancel::quiet(|| encode_with_warnings(doc, format, options))?; for w in notes { if !warnings.contains(&w) { warnings.push(w); @@ -694,8 +766,14 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { // A whole-document copy of an artboard, for the formats that write documents to edit. let mut board_docs: std::collections::HashMap> = std::collections::HashMap::new(); let mut files = 0u64; + // One step per item and row, the files a name collision skips included. + let steps = (items.len() * rows.len()) as u64; + let mut step = 0u64; for (index, item) in items.iter().enumerate() { for r in &rows { + vectorcraft_doc::cancel::check()?; + vectorcraft_doc::cancel::progress(step, steps, &format!("Exporting file {} of {steps}", step + 1)); + step += 1; let (sub, file) = naming.file(item, index, files, r); let name = sub.map_or_else(|| file.clone(), |d| format!("{d}/{file}")); let Some(name) = out.claim(&name, &format!("{}|{}", item.stem, r.key), o.name.is_some()) else { continue }; @@ -711,7 +789,7 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { } else { write(&item.doc, r.format.id, &r.options_for(item.region, item.artboard)?)? }; - out.put(folder, o.zip, name, bytes)?; + out.put(folder, o.zip, name, bytes, writer)?; files += 1; } } @@ -722,7 +800,8 @@ pub(crate) fn export(s: &mut Session, p: &Value) -> Result { return match folder { Some(dir) => { let path = format!("{dir}/{zip_name}"); - write_file(&path, &zip)?; + vectorcraft_doc::cancel::check()?; + writer(&path, &zip)?; Ok(json!({ "path": path, "bytes": zip.len(), "files": names, "warnings": warnings })) } None => Ok( diff --git a/crates/engine/src/cmd/fileio/tests_screens.rs b/crates/engine/src/cmd/fileio/tests_screens.rs index 9568a1332..867911681 100644 --- a/crates/engine/src/cmd/fileio/tests_screens.rs +++ b/crates/engine/src/cmd/fileio/tests_screens.rs @@ -1,6 +1,8 @@ //! Export for Screens: sizes (factor, width, height, resolution), presets and sub-folders, Full //! Document, Include Bleed, per-format settings, ZIP downloads and the settings the document keeps. +use std::collections::BTreeMap; + use serde_json::{Value, json}; use super::screens::ScreenSize; @@ -257,6 +259,169 @@ fn a_zip_is_written_into_the_folder() { let _ = std::fs::remove_dir_all(dir); } +/// The reply of a prepared export's [`super::ScreenExport::write`] and the files it gave the +/// writer, by path. +fn write_prepared(export: super::ScreenExport) -> (Value, BTreeMap>) { + let mut files = BTreeMap::new(); + let mut write = |path: &str, bytes: &[u8]| { + files.insert(path.to_string(), bytes.to_vec()); + Ok(()) + }; + let r = export.write(&mut write).unwrap_or_else(|e| panic!("{e}")); + (r, files) +} + +/// The files reply `r` names (its `files`, else the zip at its `path`) as they are on disk, by path. +fn on_disk(r: &Value) -> BTreeMap> { + let paths: Vec<&str> = match r["path"].as_str() { + Some(zip) => vec![zip], + None => r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(), + }; + paths.into_iter().map(|p| (p.to_string(), std::fs::read(p).unwrap())).collect() +} + +/// [`Session::prepare_screen_export`] runs `document.exportForScreens` and `assets.export` as +/// commands, journaled, and writes nothing; the export it gives writes the files the command +/// writes, with the same names (`-2` included), sub-folders, bytes and reply, into a folder, as a +/// zip in it and without a folder, through the writer it is given. Export for Screens remembers +/// its settings when it is prepared. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_writes_what_the_command_writes() { + let mut s = session(2); + s.execute("artboard.setProps", &json!({"index": 0, "name": "Icon"})).unwrap(); + s.execute("artboard.setProps", &json!({"index": 1, "name": "icon"})).unwrap(); + let dir = std::env::temp_dir().join(format!("vc-screens-prepared-{}", std::process::id())); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let rows = json!([{"format": "png", "suffix": ""}, {"format": "png", "scale": "2x"}, {"format": "svg"}]); + for zip in [false, true] { + let p = json!({"folder": folder, "zip": zip, "folders": "rows", "name": "", "formats": rows}); + let want = export(&mut s, p.clone()); + if !zip { + let names = ["1x/Icon.png", "2x/Icon@2x.png", "SVG/Icon.svg", "1x/icon-2.png", "2x/icon@2x-2.png", "SVG/icon-2.svg"]; + assert_eq!(want["files"], json!(names.map(|n| format!("{folder}/{n}")))); + } + let disk = on_disk(&want); + std::fs::remove_dir_all(&dir).unwrap(); + let journaled = s.journal.len(); + let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap(); + assert_eq!((s.journal.len(), s.journal.last().map(|(id, _)| id.as_str())), (journaled + 1, Some("document.exportForScreens"))); + assert!(!dir.exists(), "nothing is written until the export writes"); + let (got, files) = write_prepared(prepared); + assert_eq!(got, want, "zip: {zip}"); + assert_eq!(files, disk, "zip: {zip}"); + let _ = std::fs::remove_dir_all(&dir); + } + // Without a folder, the files come back in the reply and nothing is written. + let p = json!({"name": "", "formats": rows}); + let want = export(&mut s, p.clone()); + let (got, files) = write_prepared(s.prepare_screen_export("document.exportForScreens", &p).unwrap()); + assert_eq!((got, files.len()), (want, 0)); + // The settings are remembered when the export is prepared. + s.prepare_screen_export("document.exportForScreens", &json!({"prefix": "pre-", "formats": rows})).unwrap(); + assert_eq!(s.doc().unwrap().doc.export_settings.get("prefix"), Some(&json!("pre-"))); + // The Asset Export panel's export. + s.execute("select.all", &json!({})).unwrap(); + s.execute("assets.add", &json!({"multiple": false})).unwrap(); + let p = json!({"folder": folder, "formats": rows}); + let want = s.execute("assets.export", &p).unwrap(); + let disk = on_disk(&want); + assert_eq!(disk.len(), 3, "{want}"); + std::fs::remove_dir_all(&dir).unwrap(); + let prepared = s.prepare_screen_export("assets.export", &p).unwrap(); + assert_eq!(s.journal.last().map(|(id, _)| id.as_str()), Some("assets.export")); + assert_eq!(write_prepared(prepared), (want, disk)); + // An encoder's refusal names the command, as the command's own reply does. + let p = json!({"folder": folder, "formats": [{"format": "bmp", "colorModel": "cmyk"}]}); + let want = s.execute("assets.export", &p).unwrap_err(); + let got = s.prepare_screen_export("assets.export", &p).unwrap().write(&mut |_: &str, _: &[u8]| Ok(())).unwrap_err(); + assert!(matches!(&got, crate::EngineError::BadParams { cmd, .. } if cmd == "assets.export"), "{got:?}"); + assert_eq!(got.to_string(), want.to_string()); + // The checks come first, as the command's do. + let journaled = s.journal.len(); + assert!(s.prepare_screen_export("document.exportForScreens", &json!({"range": "9"})).is_err()); + assert_eq!(s.journal.len(), journaled); + assert!(s.screens_hand_off.is_none()); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_prepared_export_holds_the_document_as_it_was() { + let mut s = session(1); + let p = json!({"formats": [{"format": "svg"}]}); + let want = export(&mut s, p.clone()); + let prepared = s.prepare_screen_export("document.exportForScreens", &p).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + s.execute("edit.clear", &json!({})).unwrap(); + assert_ne!(export(&mut s, p), want, "the edit changes the file"); + assert_eq!(write_prepared(prepared).0, want); +} + +/// Output All Blacks as Rich Black (Appearance of Black › Printing / Exporting) makes the RGB files +/// draw a CMYK black as RGB black, prepared or not. +#[test] +fn rich_black_reaches_the_files() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 40, "height": 30, "colorMode": "cmyk"})).unwrap(); + s.execute("paint.setStroke", &json!({"none": true})).unwrap(); + s.execute("paint.setFill", &json!({"color": {"c": 0, "m": 0, "y": 0, "k": 1}})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 40, "height": 30})).unwrap(); + let p = json!({"formats": [{"format": "png"}]}); + let px = |r: &Value| image::load_from_memory(&files(r)[0].1).unwrap().to_rgba8().get_pixel(10, 10).0; + let accurate = px(&export(&mut s, p.clone())); + assert!(accurate[0] > 10, "accurate: dark grey, {accurate:?}"); + s.execute("prefs.set", &json!({"key": "blackOutput", "value": "rich"})).unwrap(); + assert_eq!(px(&export(&mut s, p.clone())), [0, 0, 0, 255]); + let (prepared, _) = write_prepared(s.prepare_screen_export("document.exportForScreens", &p).unwrap()); + assert_eq!(px(&prepared), [0, 0, 0, 255]); +} + +/// A cancel stops a prepared export before its next file; the file written before it stays. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_canceled_export_stops_before_its_next_file() { + let mut s = session(3); + let dir = std::env::temp_dir().join(format!("vc-screens-cancel-{}", std::process::id())); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let prepared = s.prepare_screen_export("document.exportForScreens", &json!({"folder": folder, "formats": [{"format": "svg"}]})).unwrap(); + let progress = super::progress::Progress::new(); + let mut written = vec![]; + let r = super::progress::scope(&progress, || { + prepared.write(&mut |path: &str, _: &[u8]| { + written.push(path.to_string()); + progress.cancel(); + Ok(()) + }) + }); + assert!(matches!(r, Err(crate::EngineError::Cancelled)), "{r:?}"); + assert_eq!(written, [format!("{folder}/Artboard-1.svg")]); + let _ = std::fs::remove_dir_all(dir); +} + +/// A prepared export reports one progress step per file, and the raster encoders' artboard steps +/// are not reported. +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn a_prepared_export_counts_its_files() { + let mut s = session(3); + let dir = std::env::temp_dir().join(format!("vc-screens-steps-{}", std::process::id())); + let folder = dir.to_string_lossy().replace(std::path::MAIN_SEPARATOR, "/"); + let rows = json!([{"format": "png"}, {"format": "png", "scale": "2x"}]); + let prepared = s.prepare_screen_export("document.exportForScreens", &json!({"folder": folder, "formats": rows})).unwrap(); + let progress = super::progress::Progress::of_files(); + let mut seen = vec![]; + let r = super::progress::scope(&progress, || { + prepared.write(&mut |_: &str, _: &[u8]| { + seen.push(progress.counts()); + Ok(()) + }) + }); + assert!(r.is_ok(), "{r:?}"); + assert_eq!(seen, (0..6).map(|n| (n, 6)).collect::>()); + assert!(progress.counts_files()); + let _ = std::fs::remove_dir_all(dir); +} + /// The warnings in `r` that contain `text`. fn warned<'a>(r: &'a Value, text: &str) -> Vec<&'a str> { r["warnings"].as_array().unwrap().iter().filter_map(Value::as_str).filter(|w| w.contains(text)).collect() diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8486a82c2..45f634684 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1405,6 +1405,10 @@ pub struct Session { /// While [`Session::prepare_package`] runs `file.package`: the package the command prepared, /// which it leaves here instead of writing it; `None` otherwise. pub(crate) package_hand_off: Option>, + /// While [`Session::prepare_screen_export`] runs `document.exportForScreens` or + /// `assets.export`: the export the command prepared, which it leaves here instead of writing + /// it; `None` otherwise. + pub(crate) screens_hand_off: Option>, /// Where Data Recovery keeps its copies ([`cmd::recovery`]). pub recovery: cmd::recovery::Recovery, /// While the active tool's actions run: their commands aren't "a command from outside the @@ -1508,6 +1512,7 @@ impl Session { queue_hand_off: None, place_cursor_requests: false, package_hand_off: None, + screens_hand_off: None, recovery: Default::default(), in_tool_actions: false, previewing: false, diff --git a/crates/ui-egui/src/dialogs/export_for_screens.rs b/crates/ui-egui/src/dialogs/export_for_screens.rs index e8fc11484..932eb3166 100644 --- a/crates/ui-egui/src/dialogs/export_for_screens.rs +++ b/crates/ui-egui/src/dialogs/export_for_screens.rs @@ -11,6 +11,8 @@ //! `YYYYMMDD`…, see `document.exportForScreens`) names every file alike, with a live example, and //! the files go into one folder, or a folder per row, format, item, or item and format //! (`folders`). Export presets (built-in and saved) set the formats, naming and folders at once. +//! On the desktop with Background Export on, the files are encoded and written on the background +//! thread ([`crate::background`]) after the dialog closes. //! //! Fields (`ui.dialog.set`): `tab` (artboards|assets|layers|selection), `select` (all|range|full), `range`, //! `boards` ([bool] per artboard), `assets` ([asset id] checked on the Assets tab), `includeBleed`, diff --git a/crates/ui-egui/src/dialogs/tests_screens.rs b/crates/ui-egui/src/dialogs/tests_screens.rs index 9fb40676f..74132e4db 100644 --- a/crates/ui-egui/src/dialogs/tests_screens.rs +++ b/crates/ui-egui/src/dialogs/tests_screens.rs @@ -1,9 +1,10 @@ //! Export for Screens, drawn headlessly: the fields it opens on (defaults, then the document's last //! settings, and the artboards the Artboard tool has selected), the artboard choice, Format -//! Settings, desktop folders and web downloads. +//! Settings, desktop folders, the export on the background thread and web downloads. use std::cell::RefCell; use std::rc::Rc; +use std::sync::Arc; use serde_json::{Value, json}; use vectorcraft_engine::Session; @@ -13,6 +14,8 @@ use vectorcraft_tools::{Mods, PointerEvent, PointerKind}; use super::tests_export::{frame, kind, set}; use super::*; use crate::Services; +use crate::background; +use crate::tests_background::{Files, Gate, written}; type Log = Rc)>>>; @@ -214,6 +217,202 @@ fn exports_into_the_folder_and_shows_it() { let _ = std::fs::remove_dir_all(dir); } +/// [`app`] on the desktop with Background Export's thread-safe writer, which records the files it +/// is given once `gate` opens. +fn app_with_worker(artboards: usize, gate: &Gate) -> (VectorcraftApp, Log, Files) { + let (mut app, log) = app(artboards, false); + let (files, g) = (Files::default(), gate.clone()); + let recorded = files.clone(); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + g.wait(); + recorded.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + (app, log, files) +} + +fn background_export(app: &mut VectorcraftApp, on: bool) { + app.session.execute("prefs.set", &json!({"values": {"backgroundExport": on}})).unwrap(); +} + +/// The files reply `r` names (its `files`, else the ZIP at its `path`) as they are on disk. +fn on_disk(r: &Value) -> Vec<(String, Vec)> { + let paths: Vec<&str> = match r["path"].as_str() { + Some(zip) => vec![zip], + None => r["files"].as_array().unwrap().iter().map(|f| f.as_str().unwrap()).collect(), + }; + paths.into_iter().map(|p| (p.to_string(), std::fs::read(p).unwrap())).collect() +} + +/// With Background Export on, OK closes the dialog with nothing written, and the background thread +/// writes the files OK writes at once with it off: the same names (`-2` included), sub-folders and +/// bytes, and the same status; Open Location shows the first file once it is written. The same +/// holds for one ZIP in the folder. +#[test] +fn the_files_are_written_in_the_background_as_at_once() { + let rows = json!([{"format": "png", "scale": "1x", "suffix": ""}, {"format": "png", "scale": "2x", "suffix": "@2x"}, {"format": "svg", "scale": "1x", "suffix": ""}]); + let dir = temp_folder("background"); + let folder = dir.to_string_lossy().replace('\\', "/"); + for zip in [false, true] { + let gate = Gate::default(); + let (mut app, log, files) = app_with_worker(2, &gate); + app.run("artboard.setProps", json!({"index": 0, "name": "Icon"})).unwrap(); + app.run("artboard.setProps", json!({"index": 1, "name": "icon"})).unwrap(); + let export = |app: &mut VectorcraftApp| { + if zip { + let p = json!({"folder": folder, "zip": true, "folders": "rows", "name": "", "formats": rows, "openLocation": true}); + return app.run("file.exportForScreens", p); + } + open(app); + set(app, "folder", json!(folder)); + set(app, "folders", json!("rows")); + set(app, "name", json!("")); + set(app, "formats", rows.clone()); + confirm(app) + }; + background_export(&mut app, false); + let r = export(&mut app).unwrap(); + let want = on_disk(&r); + if !zip { + assert_eq!(want.len(), 6, "{r}"); + assert_eq!(want[3].0, format!("{folder}/1x/icon-2.png")); + } + let (status, shown) = (app.ui.status.clone(), log.borrow().clone()); + assert_eq!(shown.len(), 1); + std::fs::remove_dir_all(&dir).unwrap(); + log.borrow_mut().clear(); + background_export(&mut app, true); + let r = export(&mut app).unwrap(); + assert_eq!(r["background"], true, "{r}"); + assert!(app.ui.dialog.is_none(), "closed"); + assert_eq!(app.ui.status, format!("Exporting {}…", dir.file_name().unwrap().to_string_lossy())); + assert!(written(&files).is_empty() && log.borrow().is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!(*files.lock().unwrap(), want, "zip: {zip}"); + assert_eq!((app.ui.status.clone(), log.borrow().clone()), (status, shown)); + let _ = std::fs::remove_dir_all(&dir); + } +} + +#[test] +fn the_asset_export_panel_writes_in_the_background_as_at_once() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + app.run("select.all", json!({})).unwrap(); + app.run("assets.add", json!({"multiple": false})).unwrap(); + let dir = temp_folder("assets-background"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let p = json!({"folder": folder, "formats": [{"format": "png", "scale": "2x", "suffix": "@2x"}, {"format": "svg"}]}); + background_export(&mut app, false); + let want = on_disk(&crate::io::export_files(&mut app, "assets.export", p.clone()).unwrap()); + assert_eq!(want.len(), 2); + let status = app.ui.status.clone(); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(crate::io::export_files(&mut app, "assets.export", p).unwrap()["background"], true); + assert!(written(&files).is_empty(), "nothing written yet"); + gate.open(); + background::wait_all(&mut app); + assert_eq!((files.lock().unwrap().clone(), app.ui.status.clone()), (want, status)); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn edits_after_ok_do_not_reach_the_files() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("snapshot"); + let p = json!({"folder": dir.to_string_lossy().replace('\\', "/"), "formats": [{"format": "svg"}]}); + background_export(&mut app, false); + let want = on_disk(&app.run("file.exportForScreens", p.clone()).unwrap()); + std::fs::remove_dir_all(&dir).unwrap(); + background_export(&mut app, true); + assert_eq!(app.run("file.exportForScreens", p).unwrap()["background"], true); + app.run("select.all", json!({})).unwrap(); + app.run("edit.clear", json!({})).unwrap(); + app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap(); + gate.open(); + background::wait_all(&mut app); + assert_eq!(*files.lock().unwrap(), want, "the document as it was at OK"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Cancel stops the export before its next file; the file being written when it came stays. +#[test] +fn cancel_stops_the_export_before_its_next_file() { + let (gate, writing) = (Gate::default(), Gate::default()); + let (mut app, _) = app(3, false); + let files = Files::default(); + let (f, g, w) = (files.clone(), gate.clone(), writing.clone()); + app.services.write_shared = Some(Arc::new(move |p: &str, b: &[u8]| { + w.open(); + g.wait(); + f.lock().unwrap().push((p.to_string(), b.to_vec())); + Ok(()) + })); + let dir = temp_folder("cancel"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let r = app.run("file.exportForScreens", json!({"folder": folder, "formats": [{"format": "svg"}]})).unwrap(); + assert_eq!(r["background"], true, "{r}"); + writing.wait(); + assert_eq!(app.run("file.cancelExport", json!({})).unwrap()["cancelled"], 1); + gate.open(); + background::wait_all(&mut app); + assert_eq!(written(&files), [format!("{folder}/Artboard-1.svg")]); + assert_eq!(app.ui.status, crate::io::EXPORT_CANCELLED); + let _ = std::fs::remove_dir_all(&dir); +} + +/// With Background Export turned off, an export into the same folder is written after the one in +/// the background. +#[test] +fn an_export_made_at_once_waits_for_one_in_the_background() { + let gate = Gate::default(); + let (mut app, _, files) = app_with_worker(1, &gate); + let dir = temp_folder("twice"); + let folder = dir.to_string_lossy().replace('\\', "/"); + let p = json!({"folder": folder, "formats": [{"format": "svg"}]}); + assert_eq!(app.run("file.exportForScreens", p.clone()).unwrap()["background"], true); + background_export(&mut app, false); + let opener = gate.clone(); + let release = std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_millis(500)); + opener.open(); + }); + let r = app.run("file.exportForScreens", p).unwrap(); + assert!(app.background.jobs.is_empty(), "the export in the background was written first"); + release.join().unwrap(); + let file = format!("{folder}/Artboard-1.svg"); + assert_eq!((written(&files), r["files"].clone()), (vec![file.clone()], json!([file]))); + assert_eq!(app.ui.status, format!("Exported 1 file(s) to {folder}")); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Without a folder (a script or an agent on the desktop) the files come back in the reply at once, +/// with Background Export on too. +#[test] +fn an_export_without_a_folder_replies_with_its_files() { + let (mut app, _, files) = app_with_worker(1, &Gate::default()); + let r = app.run("file.exportForScreens", json!({"formats": [{"format": "svg"}]})).unwrap(); + assert!(r.get("background").is_none(), "{r}"); + assert_eq!(r["files"][0]["name"], "Artboard-1.svg"); + assert!(app.background.jobs.is_empty() && written(&files).is_empty()); +} + +/// The command's refusal keeps the dialog open and starts nothing, as at once. +#[test] +fn a_refused_export_keeps_the_dialog_open_with_background_export_on() { + let (mut app, _, files) = app_with_worker(2, &Gate::default()); + open(&mut app); + set(&mut app, "folder", json!(temp_folder("refused").to_string_lossy().replace('\\', "/"))); + set(&mut app, "formats", json!([{"format": "dxf", "scale": "1x", "suffix": ""}])); + let e = confirm(&mut app).unwrap_err(); + assert!(e.contains("Export for Screens writes png"), "{e}"); + assert_eq!((kind(&app), app.ui.status.as_str()), (Some(export_for_screens::KIND), e.as_str())); + assert!(app.background.jobs.is_empty() && written(&files).is_empty()); +} + #[test] fn the_web_downloads_one_zip_or_the_file() { let (mut app, log) = app(2, true); diff --git a/crates/ui-egui/src/export_progress.rs b/crates/ui-egui/src/export_progress.rs index 65bcc29c7..0a406254f 100644 --- a/crates/ui-egui/src/export_progress.rs +++ b/crates/ui-egui/src/export_progress.rs @@ -1,8 +1,9 @@ //! The export progress dialog: while an export runs in the background (Background Export) a modal //! window shows what is being exported, how far it has got (artboard by artboard for raster -//! exports of several artboards; busy for one artboard and for formats made in one go, which -//! report no steps) and two buttons: Cancel (`file.cancelExport`, also -//! Esc) stops it and writes nothing, Continue in Background (`file.exportInBackground`) closes the +//! exports of several artboards, file by file for Export for Screens; busy for one artboard and for +//! formats made in one go, which report no steps) and two buttons: Cancel (`file.cancelExport`, also +//! Esc) stops it and writes nothing (Export for Screens keeps the files it wrote before the +//! Cancel), Continue in Background (`file.exportInBackground`) closes the //! dialog and leaves the export to the status bar. //! //! It opens after [`DELAY`] so a quick export doesn't flash it, and waits while another dialog is @@ -22,8 +23,9 @@ fn seen_id() -> egui::Id { egui::Id::new("export-progress-seen") } -/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one). -fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize)> { +/// What the dialog shows: (label, steps done, steps in all, share done, exports after this one, +/// whether the steps are files). +fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize, bool)> { let jobs: Vec<_> = app.background.jobs.iter().filter(|j| !j.hidden && !j.cancelled()).filter(|j| j.progress.is_some()).collect(); let first = jobs.first()?; let since = ctx.data_mut(|m| { @@ -38,7 +40,8 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, } let (done, total) = first.progress.as_ref().map(|p| p.counts()).unwrap_or_default(); let frac = first.progress.as_ref().and_then(|p| p.fraction()).unwrap_or(0.0) as f32; - Some((first.label.clone(), done, total, frac, jobs.len() - 1)) + let files = first.progress.as_ref().is_some_and(|p| p.counts_files()); + Some((first.label.clone(), done, total, frac, jobs.len() - 1, files)) } /// Draws the window for the first export running in the background that wasn't hidden or @@ -46,7 +49,7 @@ fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, /// of the button pressed. pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let now = ctx.input(|i| i.time); - let Some((label, done, total, frac, more)) = shown(app, now, ctx) else { return }; + let Some((label, done, total, frac, more, files)) = shown(app, now, ctx) else { return }; if app.ui.dialog.is_some() { return; } @@ -55,7 +58,8 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) { let pressed = window(ctx, "export-progress", &heading, |ui| { // One step says nothing a spinner doesn't: the bar would sit at 0 % until the file is written. if total > 1 { - let step = crate::i18n::fmt(tl!("Artboard {n} of {total}"), &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]); + let step = if files { tl!("File {n} of {total}") } else { tl!("Artboard {n} of {total}") }; + let step = crate::i18n::fmt(step, &[("n", &(done + 1).min(total).to_string()), ("total", &total.to_string())]); // The count on its own line: drawn over the bar it would straddle the fill's edge. ui.horizontal(|ui| { ui.label(egui::RichText::new(step).color(t.text_dim)); @@ -225,4 +229,42 @@ mod tests { background::wait_all(&mut app); assert!(app.background.jobs.is_empty()); } + + #[test] + fn an_export_of_files_counts_files() { + let go = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let mut app = app(go.clone()); + let p = Progress::of_files(); + let reporting = p.clone(); + background::run_with_progress( + &mut app, + true, + "Exporting icons".into(), + None, + Some(p.clone()), + move |w| { + vectorcraft_engine::cmd::fileio::progress::scope(&reporting, || { + vectorcraft_doc::cancel::progress(1, 3, "file 2 of 3"); + w("/icons/b.png", b"x").map(|()| json!({})) + }) + }, + |_, r| r, + ) + .unwrap(); + // The worker reports the step before it waits on the writer. + for _ in 0..10_000 { + if p.counts() == (1, 3) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + frame(&mut app, &ctx, 0.0); + frame(&mut app, &ctx, 1.0); + let shown = frame(&mut app, &ctx, 1.1); + assert!(shown.contains("File 2 of 3") && !shown.contains("Artboard"), "{shown}"); + go.store(true, std::sync::atomic::Ordering::SeqCst); + background::wait_all(&mut app); + } } diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index d00dd9145..539ebd2aa 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -5316,6 +5316,7 @@ theme Appearance Външен вид @msg unknown snap option `{key}` (use {options}) неизвестна опция за прилепване `{key}` (използвайте {options}) Gap Length: Дължина на пропуска: Artboard {n} of {total} Работна област {n} от {total} + File {n} of {total} Файл {n} от {total} Encoding and writing the file… Кодиране и записване на файла… @plural {n} more export waiting|{n} more exports waiting Още {n} експортиране в изчакване|Още {n} експортирания в изчакване Continue in Background Продължаване във фонов режим diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv index 537bac983..6c9ec4b3b 100644 --- a/crates/ui-egui/src/i18n/cs.tsv +++ b/crates/ui-egui/src/i18n/cs.tsv @@ -5362,6 +5362,7 @@ mark-style Japanese Japonské @msg unknown snap option `{key}` (use {options}) neznámá možnost přichytávání `{key}` (použijte {options}) Gap Length: Délka mezery: Artboard {n} of {total} Pracovní plocha {n} z {total} + File {n} of {total} Soubor {n} z {total} Encoding and writing the file… Kódování a zápis souboru… @plural {n} more export waiting|{n} more exports waiting Čeká ještě {n} export|Čekají ještě {n} exporty|Čeká ještě {n} exportů Continue in Background Pokračovat na pozadí diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv index 24bb20224..3a0ecd082 100644 --- a/crates/ui-egui/src/i18n/da.tsv +++ b/crates/ui-egui/src/i18n/da.tsv @@ -5312,6 +5312,7 @@ swatch Bright Violet Kraftig violet @msg unknown snap option `{key}` (use {options}) ukendt fastgørelsesindstilling `{key}` (brug {options}) Gap Length: Hullængde: Artboard {n} of {total} Tegneflade {n} af {total} + File {n} of {total} Fil {n} af {total} Encoding and writing the file… Koder og skriver filen… @plural {n} more export waiting|{n} more exports waiting {n} eksport mere venter|{n} eksporter mere venter Continue in Background Fortsæt i baggrunden diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index fbc8ac81f..029025c24 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -4942,6 +4942,7 @@ effect Soft Weich @msg unknown snap option `{key}` (use {options}) unbekannte Einrastoption `{key}` (möglich: {options}) Gap Length: Lückenlänge: Artboard {n} of {total} Zeichenfläche {n} von {total} + File {n} of {total} Datei {n} von {total} Encoding and writing the file… Datei wird codiert und geschrieben… @plural {n} more export waiting|{n} more exports waiting {n} weiterer Export wartet|{n} weitere Exporte warten Continue in Background Im Hintergrund fortsetzen diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv index f2e38fc52..f73310335 100644 --- a/crates/ui-egui/src/i18n/el.tsv +++ b/crates/ui-egui/src/i18n/el.tsv @@ -5314,6 +5314,7 @@ effect Points Σημεία @msg unknown snap option `{key}` (use {options}) άγνωστη επιλογή προσκόλλησης `{key}` (χρησιμοποιήστε {options}) Gap Length: Μήκος κενού: Artboard {n} of {total} Πίνακας σχεδίασης {n} από {total} + File {n} of {total} Αρχείο {n} από {total} Encoding and writing the file… Κωδικοποίηση και εγγραφή του αρχείου… @plural {n} more export waiting|{n} more exports waiting {n} ακόμη εξαγωγή σε αναμονή|{n} ακόμη εξαγωγές σε αναμονή Continue in Background Συνέχεια στο παρασκήνιο diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 7f0b988c8..30de2fab6 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -5304,6 +5304,7 @@ mark-style Japanese Japonesas @msg unknown snap option `{key}` (use {options}) opción de ajuste desconocida `{key}` (use {options}) Gap Length: Longitud del hueco: Artboard {n} of {total} Mesa de trabajo {n} de {total} + File {n} of {total} Archivo {n} de {total} Encoding and writing the file… Codificando y escribiendo el archivo… @plural {n} more export waiting|{n} more exports waiting {n} exportación más en espera|{n} exportaciones más en espera Continue in Background Continuar en segundo plano diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv index af5083a0a..013897837 100644 --- a/crates/ui-egui/src/i18n/fi.tsv +++ b/crates/ui-egui/src/i18n/fi.tsv @@ -5326,6 +5326,7 @@ mark-style Japanese Japanilainen @msg unknown snap option `{key}` (use {options}) tuntematon tartuntavalinta `{key}` (käytä: {options}) Gap Length: Aukon pituus: Artboard {n} of {total} Piirtoalusta {n}/{total} + File {n} of {total} Tiedosto {n}/{total} Encoding and writing the file… Tiedostoa koodataan ja kirjoitetaan… @plural {n} more export waiting|{n} more exports waiting {n} vienti odottaa|{n} vientiä odottaa Continue in Background Jatka taustalla diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 10d6ce49c..f483d4c52 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -5361,6 +5361,7 @@ theme Appearance Apparence @msg unknown snap option `{key}` (use {options}) option de magnétisme inconnue `{key}` (utilisez {options}) Gap Length: Longueur de lacune : Artboard {n} of {total} Plan de travail {n} sur {total} + File {n} of {total} Fichier {n} sur {total} Encoding and writing the file… Encodage et écriture du fichier… @plural {n} more export waiting|{n} more exports waiting {n} autre exportation en attente|{n} autres exportations en attente Continue in Background Continuer en arrière-plan diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 2e022401d..1ee8dbd5a 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -5348,6 +5348,7 @@ imageTrace Outline Körvonal @msg unknown snap option `{key}` (use {options}) ismeretlen illesztési beállítás: `{key}` (használható: {options}) Gap Length: Rés hossza: Artboard {n} of {total} Rajztábla: {n} / {total} + File {n} of {total} Fájl: {n} / {total} Encoding and writing the file… A fájl kódolása és írása… @plural {n} more export waiting|{n} more exports waiting Még {n} exportálás várakozik Continue in Background Folytatás a háttérben diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv index 0c4992ed3..06d45e8e5 100644 --- a/crates/ui-egui/src/i18n/id.tsv +++ b/crates/ui-egui/src/i18n/id.tsv @@ -5312,6 +5312,7 @@ swatch Bright Violet Violet cerah @msg unknown snap option `{key}` (use {options}) opsi snap `{key}` tidak dikenal (gunakan {options}) Gap Length: Panjang celah: Artboard {n} of {total} Artboard {n} dari {total} + File {n} of {total} Berkas {n} dari {total} Encoding and writing the file… Mengodekan dan menulis berkas… @plural {n} more export waiting|{n} more exports waiting {n} ekspor lagi menunggu Continue in Background Lanjutkan di latar belakang diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 182a50654..84c74805a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -4976,6 +4976,7 @@ lighting Light Luce @msg unknown snap option `{key}` (use {options}) opzione di aggancio sconosciuta `{key}` (usare {options}) Gap Length: Lunghezza lacuna: Artboard {n} of {total} Tavola da disegno {n} di {total} + File {n} of {total} File {n} di {total} Encoding and writing the file… Codifica e scrittura del file… @plural {n} more export waiting|{n} more exports waiting {n} altra esportazione in attesa|{n} altre esportazioni in attesa Continue in Background Continua in background diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 15cd43890..0abddc792 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -5016,6 +5016,7 @@ lighting Light ライト @msg unknown snap option `{key}` (use {options}) 不明なスナップオプション `{key}` ({options} を使用してください) Gap Length: ギャップの長さ: Artboard {n} of {total} アートボード {n} / {total} + File {n} of {total} ファイル {n} / {total} Encoding and writing the file… ファイルをエンコードして書き込み中… @plural {n} more export waiting|{n} more exports waiting 待機中の書き出し: あと {n} 件 Continue in Background バックグラウンドで続行 diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv index fb9c86ed7..a38434022 100644 --- a/crates/ui-egui/src/i18n/ka.tsv +++ b/crates/ui-egui/src/i18n/ka.tsv @@ -5237,6 +5237,7 @@ imageTrace Outline კონტური Clear History ისტორიის გასუფთავება Continue in Background ფონურ რეჟიმში გაგრძელება Artboard {n} of {total} სამუშაო დაფა {n} / {total} + File {n} of {total} ფაილი {n} / {total} Auto Leading: ავტომატური სტრიქონთა დაშორება: Desired სასურველი Encoding and writing the file… ფაილის კოდირება და ჩაწერა… diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv index 459dbfb2d..e3e962715 100644 --- a/crates/ui-egui/src/i18n/ko.tsv +++ b/crates/ui-egui/src/i18n/ko.tsv @@ -4939,6 +4939,7 @@ lighting Light 광원 @msg unknown snap option `{key}` (use {options}) 알 수 없는 스냅 옵션 `{key}` ({options} 중에서 사용하세요) Gap Length: 간격 길이: Artboard {n} of {total} 아트보드 {n} / {total} + File {n} of {total} 파일 {n} / {total} Encoding and writing the file… 파일을 인코딩하고 쓰는 중… @plural {n} more export waiting|{n} more exports waiting 대기 중인 내보내기 {n}개 더 Continue in Background 백그라운드에서 계속 diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv index dbb408ee7..aa093f890 100644 --- a/crates/ui-egui/src/i18n/nl.tsv +++ b/crates/ui-egui/src/i18n/nl.tsv @@ -5355,6 +5355,7 @@ swatch Bright Violet Felviolet @msg The image is {_1} × {_2} pixels, more than {LARGE_TRACE_SIDE} on a side: it is traced, but more slowly. A lower-fidelity preset or a smaller copy of the image traces faster. De afbeelding is {_1} × {_2} pixels, meer dan {LARGE_TRACE_SIDE} aan één zijde: ze wordt getraceerd, maar langzamer. Een voorinstelling met minder detail of een kleinere kopie van de afbeelding traceert sneller. @msg The image was traced at {_1} × {_2} pixels, scaled down from {_3} × {_4} ({_5} megapixels at most). De afbeelding is getraceerd op {_1} × {_2} pixels, verkleind van {_3} × {_4} (maximaal {_5} megapixels). Artboard {n} of {total} Tekengebied {n} van {total} + File {n} of {total} Bestand {n} van {total} Encoding and writing the file… Bestand coderen en schrijven… @plural {n} more export waiting|{n} more exports waiting Nog {n} export in de wachtrij|Nog {n} exports in de wachtrij Continue in Background Doorgaan op achtergrond diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index 9a258cb36..3242e7d2d 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -5214,6 +5214,7 @@ mark-style Japanese Japońskie @msg unknown snap option `{key}` (use {options}) nieznana opcja przyciągania `{key}` (użyj: {options}) Gap Length: Długość przerwy: Artboard {n} of {total} Obszar kompozycji {n} z {total} + File {n} of {total} Plik {n} z {total} Encoding and writing the file… Kodowanie i zapisywanie pliku… @plural {n} more export waiting|{n} more exports waiting Czeka jeszcze {n} eksport|Czekają jeszcze {n} eksporty|Czeka jeszcze {n} eksportów Continue in Background Kontynuuj w tle diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 6a175a871..fc957cb52 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -5279,6 +5279,7 @@ mark-style Japanese Japonesas @msg unknown snap option `{key}` (use {options}) opção de ajuste desconhecida `{key}` (use {options}) Gap Length: Comprimento da Lacuna: Artboard {n} of {total} Mesa de Trabalho {n} de {total} + File {n} of {total} Arquivo {n} de {total} Encoding and writing the file… Codificando e gravando o arquivo… @plural {n} more export waiting|{n} more exports waiting Mais {n} exportação aguardando|Mais {n} exportações aguardando Continue in Background Continuar em Segundo Plano diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index 3cd83b0ff..c130802bf 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -4972,6 +4972,7 @@ lighting Light Свет @msg unknown snap option `{key}` (use {options}) неизвестный параметр привязки `{key}` (допустимо: {options}) Gap Length: Длина зазора: Artboard {n} of {total} Монтажная область {n} из {total} + File {n} of {total} Файл {n} из {total} Encoding and writing the file… Кодирование и запись файла… @plural {n} more export waiting|{n} more exports waiting Ещё {n} экспорт в очереди|Ещё {n} экспорта в очереди|Ещё {n} экспортов в очереди Continue in Background Продолжить в фоне diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv index 2b1c96c83..8263f503a 100644 --- a/crates/ui-egui/src/i18n/sv.tsv +++ b/crates/ui-egui/src/i18n/sv.tsv @@ -5318,6 +5318,7 @@ theme Appearance Utseende @msg unknown snap option `{key}` (use {options}) okänt fästalternativ `{key}` (använd {options}) Gap Length: Lucklängd: Artboard {n} of {total} Rityta {n} av {total} + File {n} of {total} Fil {n} av {total} Encoding and writing the file… Kodar och skriver filen… @plural {n} more export waiting|{n} more exports waiting {n} export till väntar|{n} exporter till väntar Continue in Background Fortsätt i bakgrunden diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv index f69c03473..4f9ed58bd 100644 --- a/crates/ui-egui/src/i18n/tr.tsv +++ b/crates/ui-egui/src/i18n/tr.tsv @@ -2662,6 +2662,7 @@ Desired İstenen Edit Path Yolu Düzenle Encoding and writing the file… Dosya kodlanıyor ve yazılıyor… + File {n} of {total} Dosya {n} / {total} Full Justify Tam Yasla Glyph Scaling: Glif Ölçekleme: If a drawing tablet's pen doesn't draw, choose an X11 (XWayland) option. Çizim tabletinin kalemi çizmiyorsa, bir X11 (XWayland) seçeneği belirleyin. diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index de11f155f..bf546672b 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -4956,6 +4956,7 @@ lighting Light Світло @msg unknown snap option `{key}` (use {options}) невідомий параметр прив’язки `{key}` (використовуйте {options}) Gap Length: Довжина проміжку: Artboard {n} of {total} Монтажна область {n} з {total} + File {n} of {total} Файл {n} з {total} Encoding and writing the file… Кодування та запис файлу… @plural {n} more export waiting|{n} more exports waiting Ще {n} експорт в очікуванні|Ще {n} експорти в очікуванні|Ще {n} експортів в очікуванні Continue in Background Продовжити у фоні diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv index a9edb0690..90f1cda3f 100644 --- a/crates/ui-egui/src/i18n/vi.tsv +++ b/crates/ui-egui/src/i18n/vi.tsv @@ -4956,6 +4956,7 @@ theme Appearance Giao diện @msg unknown snap option `{key}` (use {options}) tùy chọn hút không hợp lệ `{key}` (dùng {options}) Gap Length: Độ dài khoảng hở: Artboard {n} of {total} Bảng vẽ {n} / {total} + File {n} of {total} Tệp {n} / {total} Encoding and writing the file… Đang mã hóa và ghi tệp… @plural {n} more export waiting|{n} more exports waiting Còn {n} lượt xuất đang chờ Continue in Background Tiếp tục trong nền diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 51b8ed298..b3f7c47df 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -5200,6 +5200,7 @@ lighting Light 光源 @msg {id} opens another app: only the person at the keyboard can run it {id} 会打开其他应用程序:只有使用键盘的人才能运行它 @msg “{name}” is not a file “{name}” 不是文件 Artboard {n} of {total} 画板 {n} / {total} + File {n} of {total} 文件 {n} / {total} Encoding and writing the file… 正在编码并写入文件… @plural {n} more export waiting|{n} more exports waiting 另有 {n} 个导出等待中 Continue in Background 在后台继续 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 27fe70cd4..94dd8ff10 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -4990,6 +4990,7 @@ lighting Light 光源 @msg unknown snap option `{key}` (use {options}) 未知的靠齊選項 `{key}`(可用:{options}) Gap Length: 間隙長度: Artboard {n} of {total} 工作區域 {n} / {total} + File {n} of {total} 檔案 {n} / {total} Encoding and writing the file… 正在編碼並寫入檔案… @plural {n} more export waiting|{n} more exports waiting 另有 {n} 個匯出等待中 Continue in Background 在背景繼續 diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 97dcdbf7f..35c27b8ba 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -1003,10 +1003,47 @@ pub fn export_for_screens(app: &mut VectorcraftApp, params: Value) -> Result Result { let open_location = params.get("openLocation").and_then(Value::as_bool).unwrap_or(false); let folder = params.get("folder").and_then(Value::as_str).unwrap_or_default().to_string(); - let r = app.run(id, params)?; + // Without a folder the files come back in the reply, which the caller waits for. + let in_background = app.session.prefs.background_export && app.services.write_shared.is_some() && !is_web(app) && !folder.is_empty(); + if !in_background { + // Made at once, after the exports and saves started before it. + background::wait_all(app); + let r = app.run(id, params)?; + return report_exported(app, r, &folder, open_location); + } + let export = app.session.prepare_screen_export(id, ¶ms).map_err(|e| e.to_string()).inspect_err(|e| app.ui.status = e.clone())?; + let label = format!("Exporting {}", fileio::file_name(&folder)); + // The progress dialog counts the files, and its Cancel stops the export before the next one. + let progress = Progress::of_files(); + let (reporting, asked) = (progress.clone(), progress.clone()); + let work = move |write: Writer| { + progress::scope(&reporting, || { + let mut write = |path: &str, bytes: &[u8]| write(path, bytes).map_err(|e| EngineError::Other(format!("{path}: {e}"))); + export.write(&mut write).map_err(|e| e.to_string()) + }) + }; + background::run_with_progress(app, true, label, None, Some(progress), work, move |app, r| { + // Files written before the Cancel arrived stay in the folder. + if r.is_err() && asked.cancelled() { + app.status(EXPORT_CANCELLED); + return Ok(json!({ "cancelled": true })); + } + let r = r.inspect_err(|e| report_write_error(app, true, &folder, e))?; + report_exported(app, r, &folder, open_location) + }) +} + +/// Report the files `r` (the reply of `document.exportForScreens`) in the status bar: the count +/// written into `folder`, else the ZIP or the files downloaded (the web); with `open_location`, +/// the file manager shows the first file written. +fn report_exported(app: &mut VectorcraftApp, r: Value, folder: &str, open_location: bool) -> Result { let count = r["files"].as_array().map_or(0, Vec::len); let warned = r["warnings"].as_array().is_some_and(|w| !w.is_empty()); // Files that came back as bytes: (name, base64). @@ -1020,7 +1057,7 @@ pub(crate) fn export_files(app: &mut VectorcraftApp, id: &str, params: Value) -> let first = r["path"].as_str().or_else(|| r["files"][0].as_str()); let mut status = format!("Exported {count} file(s) to {folder}"); if warned { - status = saved_status(r["path"].as_str().unwrap_or(&folder), &r); + status = saved_status(r["path"].as_str().unwrap_or(folder), &r); } if open_location && let Some(first) = first diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ee391e206..fe3fba25a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -162,7 +162,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", - "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager)", + "{} opens the dialog (on the document's last settings; while the Artboard tool is in use, with the artboards it has selected chosen); with params = document.exportForScreens (no folder on the web: downloads the files or the zip; openLocation: shows the first file in the file manager; a folder on the desktop with Background Export on: the files are written in the background → {background: true, status})", ), ("file.documentSetup", "Document Setup…", "Cmd+Alt+P", "{}"), ("file.newDialog", "New…", "Cmd+N", "{} opens the New Document dialog"), diff --git a/crates/ui-egui/src/panels/asset_export.rs b/crates/ui-egui/src/panels/asset_export.rs index 3cdd9a6f6..d723dffc3 100644 --- a/crates/ui-egui/src/panels/asset_export.rs +++ b/crates/ui-egui/src/panels/asset_export.rs @@ -3,8 +3,9 @@ //! of them all), and so does the selection with the + button. Clicking selects assets (Shift or //! Cmd adds), double-clicking one renames it and the bin removes the selected ones. Below are the //! export settings, the format rows Export for Screens shows too (one set per document), and -//! Export, which writes the selected assets (else all of them) into a picked folder; the web -//! downloads them. The screen button opens Export for Screens on its Assets tab. +//! Export, which writes the selected assets (else all of them) into a picked folder (on the +//! desktop with Background Export on, on the background thread); the web downloads them. The +//! screen button opens Export for Screens on its Assets tab. //! //! Everything runs through the `assets.*` commands. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 3ca6da76f..b64acfa1f 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -118,7 +118,7 @@ the two can still win that race. | `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}`, and the status bar shows the first of the export's `warnings` as Save As shows a save's notes. Headless it returns `{dataBase64, width, height, warnings}` | | Native file dialogs | any command with no `path` that needs one (`file.open`, `file.saveAs`, `file.place`, exports…) | on macOS (a sheet on the window) and Linux the dialog doesn't hold the window: the call returns `"cancelled"` at once, `ui.inspect` reports `fileDialog`, and when the person at the computer picks a file the command runs again on the document it was asked for. While it is open another dialog is refused: that call returns `"cancelled"` too, and the status bar (`ui.inspect` → `ui.status`) says `"A file dialog is open: choose there first"`; on macOS `app.quit`, `file.close` and `file.closeAll` return that message and the menu bar does nothing (#592, #867). On Windows the dialog is shown in line: the call returns once it closes. Pass `path` to skip the dialog | -| 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 | +| Background Save / Export | `app.save`, `app.export {path}`, `file.save`, `file.saveAs`, `file.saveCopy`, `file.exportForScreens {folder, …}` | 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 (`file.exportForScreens`: `{background: true, status}`, and the files are written into the folder from a snapshot taken at the call), `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 | | `engine.execute` `file.exportForScreens` | `{}` / `{…document.exportForScreens params}` | without params opens `exportForScreens` on the settings the document last exported with (`document.exportSettings`). While the Artboard tool is in use, the artboards it has selected (its `selected` option, the rows the Artboards panel highlights) are checked instead, and `select` is `range`, or `all` when every artboard is selected. Fields: `tab` (`artboards` \| `assets` \| `layers` \| `selection`; OK reads Export Layer or Export Selection on those), `layers` ([layer id…] checked), `selectionMode` (`one` \| `each`), `name` (the file name pattern of `document.exportForScreens`), `counterStart`, `folders` (`none` \| `rows` \| `format` \| `item` \| `itemFormat`), `exportPreset` (the preset applied last), `__savePreset` (the name typed to save one), `select` (`all` \| `range` \| `full`), `range` ("1-3, 5"), `boards` (a bool per artboard; checking them sets `select` and `range`), `includeBleed`, `folder` (desktop; default the Desktop, else home), `openLocation`, `subfolders`, `prefix`, `preset` (`""` \| `mobile` \| `density`), `formats` (rows `{format, scale: "2x" \| "100w" \| "100h" \| "72ppi", suffix, quality?, folder?}`), `settings` (`{png \| png8 \| jpg \| webp \| gif \| svg \| pdf: {…options}}`, the gear's Format Settings; `__settings` names the format shown). `ui.dialog.confirm` exports (a bad range or no folder keeps it open); on the web OK reads "Download" and the files download (several as one `.zip`). With params it runs `document.exportForScreens`: no `folder` returns the files (or with `zip: true` one store-only zip) as `dataBase64`; with `openLocation` the first file is shown in the file manager. When `warnings` is not empty, an export into a folder or a download shows the first in the status bar as Save As shows a save's notes | | Export for Screens' Assets tab, Asset Export panel | `ui.dialog.set` `tab: "assets"`, `assets: [asset id…]`; `window.panel {panel: "assetExport"}` | on the Assets tab of `exportForScreens` the `assets` field lists the checked asset ids (all of them when it opens); `ui.dialog.confirm` runs `document.exportForScreens {assets}` (nothing checked keeps it open) and OK reads "Export Asset". The Asset Export panel acts through `assets.*` and shares the format rows with the dialog (`assets.settings.set`) | | `engine.execute` `file.exportSelection` | `{multiple?}` | File › Export Selection…: collects the selected objects as one asset (`assets.add`; one per object with `multiple: true`) and opens `exportForScreens` on its Assets tab with just them checked → `{assets}`. `document.exportSelection` still exports the selection in one go, without the dialog | diff --git a/docs/mcp.md b/docs/mcp.md index b4711c451..87b773607 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -271,7 +271,8 @@ while an export runs (#2165, #2171, #2172): - **Progress.** A request with `_meta.progressToken` gets `notifications/progress` ([spec](https://modelcontextprotocol.io/specification/2025-06-18/basic/utilities/progress)) before its reply: the PDF writer reports "Writing page 2 of 5" with `progress` 2 and `total` 5, raster - export reports each artboard, and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always), + export reports each artboard, `document.exportForScreens` and `assets.export` report each file + ("Exporting file 3 of 250", in place of their encoders' steps), and the PDF flattening stage reports a message without a total. `progress` only grows, updates come at most every 200 ms (the last page always), and none follows the reply. A client that does not read fast enough misses updates, never the reply. - **A busy engine.** `ping`, `tools/list`, `resources/list`, `resources/templates/list`, `prompts/list` and `prompts/get` need no engine and are answered at once, whatever is running. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d0969418f..203a82eb1 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -908,6 +908,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before. + - On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before. - Missing linked files can be looked for in a folder and its subfolders: Find in Folder… in the Linked File Not Found dialog and in the Links panel menu lists the files with each one's name, those with the size and contents saved with the link first, and relinks the files chosen, copying an image's file into the document's folder when asked, without replacing a file there (`links.findFiles`, `links.relink {copy}`). In the Links panel it looks for the selected linked files when any are selected. - On Windows, a document opened from a mapped network drive finds its links stored with the share's own path (`\\server\share\…`), as the same document opened from the share does; links on other computers stay missing. - On Windows, a link on a drive mapped to a network share other than the document's (`Y:\…`) is missing, with the reason that the path is on another computer, as a link stored with that share's own path is; links on this computer's drives and on the document's share are followed.