Files
vectorcraft/crates/ui-egui/src/export_progress.rs
T

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::unique("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::unique(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);
}
}