mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-26 18:56:55 -06:00
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.
271 lines
11 KiB
Rust
271 lines
11 KiB
Rust
//! 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, 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
|
|
//! open (the overwrite question, an options dialog): never two modals at once.
|
|
|
|
use serde_json::json;
|
|
|
|
use crate::dialogs::modal;
|
|
use crate::theme::Tokens;
|
|
use crate::{VectorcraftApp, widgets};
|
|
|
|
/// Seconds an export runs before the dialog opens.
|
|
pub const DELAY: f64 = 0.4;
|
|
|
|
/// Where the job a frame first saw is remembered (its id, the time it was first seen).
|
|
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,
|
|
/// 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| {
|
|
let seen = m.get_temp_mut_or_insert_with(seen_id(), || (first.id(), now));
|
|
if seen.0 != first.id() {
|
|
*seen = (first.id(), now);
|
|
}
|
|
seen.1
|
|
});
|
|
if now - since < DELAY {
|
|
return None;
|
|
}
|
|
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;
|
|
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
|
|
/// cancelled, once it has run [`DELAY`] seconds and no other dialog is open, and runs the command
|
|
/// 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, files)) = shown(app, now, ctx) else { return };
|
|
if app.ui.dialog.is_some() {
|
|
return;
|
|
}
|
|
let t = Tokens::get(ctx);
|
|
let heading = format!("{}…", crate::i18n::msg(&label));
|
|
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 = 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));
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
ui.label(egui::RichText::new(format!("{:.0} %", frac * 100.0)).color(t.text_dim));
|
|
});
|
|
});
|
|
ui.add_space(4.0);
|
|
ui.add(egui::ProgressBar::new(frac).desired_width(360.0).desired_height(6.0).fill(t.accent));
|
|
} else {
|
|
busy(ui, tl!("Encoding and writing the file…"));
|
|
}
|
|
if more > 0 {
|
|
ui.add_space(6.0);
|
|
let queued = crate::i18n::tn(more as u64, "{n} more export waiting", "{n} more exports waiting");
|
|
ui.label(egui::RichText::new(queued).size(11.5).color(t.text_dim));
|
|
}
|
|
});
|
|
let id = match pressed {
|
|
Some(Pressed::Cancel) => "file.cancelExport",
|
|
Some(Pressed::Hide) => "file.exportInBackground",
|
|
None => return,
|
|
};
|
|
if let Err(e) = app.run(id, json!({})) {
|
|
app.status(e);
|
|
}
|
|
}
|
|
|
|
/// A button of a progress [`window`].
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum Pressed {
|
|
/// Cancel, or Esc: stop the work.
|
|
Cancel,
|
|
/// Continue in Background: close the window; the work goes on, shown in the status bar.
|
|
Hide,
|
|
}
|
|
|
|
/// A modal window (`id`) headed `heading`, in the UI language: `body` draws how far the work has
|
|
/// got, then come Cancel (also Esc) and Continue in Background → the one pressed. Work that may
|
|
/// take a while in the background shows it: exports, and heavy plug-in filters
|
|
/// ([`crate::plugin_runs`]).
|
|
pub fn window(ctx: &egui::Context, id: &str, heading: &str, body: impl FnOnce(&mut egui::Ui)) -> Option<Pressed> {
|
|
let mut pressed = None;
|
|
modal::show(ctx, heading, egui::Id::new(id), -40.0, 20, |ui| {
|
|
ui.set_width(360.0);
|
|
modal::heading(ui, heading);
|
|
ui.add_space(12.0);
|
|
body(ui);
|
|
ui.add_space(16.0);
|
|
let row = egui::vec2(ui.min_rect().width(), ui.spacing().interact_size.y);
|
|
ui.allocate_ui_with_layout(row, egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
|
if widgets::secondary_button(ui, "Cancel").clicked() {
|
|
pressed = Some(Pressed::Cancel);
|
|
}
|
|
ui.add_space(8.0);
|
|
if widgets::secondary_button(ui, "Continue in Background").clicked() {
|
|
pressed = Some(Pressed::Hide);
|
|
}
|
|
});
|
|
});
|
|
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
|
pressed = Some(Pressed::Cancel);
|
|
}
|
|
pressed
|
|
}
|
|
|
|
/// Work that counts no steps, in a [`window`]: a spinner and `what` it does, in the UI language.
|
|
pub fn busy(ui: &mut egui::Ui, what: &str) {
|
|
let t = Tokens::get(ui.ctx());
|
|
ui.horizontal(|ui| {
|
|
ui.add(egui::Spinner::new().size(14.0).color(t.accent));
|
|
ui.label(egui::RichText::new(what).color(t.text_dim));
|
|
});
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use serde_json::json;
|
|
use vectorcraft_engine::Session;
|
|
use vectorcraft_engine::cmd::fileio::progress::Progress;
|
|
|
|
use crate::{Services, VectorcraftApp, background};
|
|
|
|
/// An app whose background writer waits until `go` is set.
|
|
fn app(go: Arc<std::sync::atomic::AtomicBool>) -> VectorcraftApp {
|
|
let services = Services {
|
|
write_shared: Some(Arc::new(move |_: &str, _: &[u8]| {
|
|
while !go.load(std::sync::atomic::Ordering::SeqCst) {
|
|
std::thread::sleep(std::time::Duration::from_millis(1));
|
|
}
|
|
Ok(())
|
|
})),
|
|
..Default::default()
|
|
};
|
|
VectorcraftApp::new(Session::new(), services)
|
|
}
|
|
|
|
/// Draws one frame of the dialog at `time` seconds → the texts it drew.
|
|
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64) -> String {
|
|
let mut out = ctx.run_ui(egui::RawInput { time: Some(time), ..Default::default() }, |ui| super::show(app, ui.ctx()));
|
|
out.textures_delta.clear();
|
|
let mut texts = String::new();
|
|
for shape in out.shapes {
|
|
if let egui::epaint::Shape::Text(t) = shape.shape {
|
|
texts.push_str(t.galley.text());
|
|
texts.push('\n');
|
|
}
|
|
}
|
|
texts
|
|
}
|
|
|
|
#[test]
|
|
fn opens_after_the_delay_and_cancel_closes_it() {
|
|
let go = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let mut app = app(go.clone());
|
|
let p = Progress::new();
|
|
let r = background::run_with_progress(
|
|
&mut app,
|
|
true,
|
|
"Exporting a.png".into(),
|
|
None,
|
|
Some(p.clone()),
|
|
|w| w("/a.png", b"x").map(|()| json!({})),
|
|
|_, r| r,
|
|
);
|
|
assert_eq!(r.unwrap()["background"], true);
|
|
let ctx = egui::Context::default();
|
|
crate::theme::install_fonts(&ctx);
|
|
assert!(!frame(&mut app, &ctx, 0.0).contains("Exporting a.png"), "not before the delay");
|
|
frame(&mut app, &ctx, 1.0); // a new window measures itself unseen first
|
|
let shown = frame(&mut app, &ctx, 1.1);
|
|
assert!(shown.contains("Exporting a.png") && shown.contains("Cancel") && shown.contains("Continue in Background"), "{shown}");
|
|
app.run("file.cancelExport", json!({})).unwrap();
|
|
assert!(p.cancelled());
|
|
assert_eq!(app.ui.status, crate::io::EXPORT_CANCELLED);
|
|
assert!(!frame(&mut app, &ctx, 2.0).contains("Exporting a.png"), "a cancelled export isn't shown");
|
|
go.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
background::wait_all(&mut app);
|
|
}
|
|
|
|
#[test]
|
|
fn continue_in_background_hides_it_and_the_export_goes_on() {
|
|
let go = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
let mut app = app(go.clone());
|
|
let p = Progress::new();
|
|
background::run_with_progress(
|
|
&mut app,
|
|
true,
|
|
"Exporting b.png".into(),
|
|
None,
|
|
Some(p.clone()),
|
|
|w| w("/b.png", b"x").map(|()| json!({})),
|
|
|_, r| r,
|
|
)
|
|
.unwrap();
|
|
let ctx = egui::Context::default();
|
|
crate::theme::install_fonts(&ctx);
|
|
frame(&mut app, &ctx, 0.0);
|
|
frame(&mut app, &ctx, 1.0);
|
|
assert!(frame(&mut app, &ctx, 1.1).contains("Exporting b.png"));
|
|
assert_eq!(app.run("file.exportInBackground", json!({})).unwrap()["hidden"], 1);
|
|
assert!(!frame(&mut app, &ctx, 2.0).contains("Exporting b.png"));
|
|
assert!(!p.cancelled());
|
|
go.store(true, std::sync::atomic::Ordering::SeqCst);
|
|
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);
|
|
}
|
|
}
|