mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-31 05:07:51 -06:00
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::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);
|
|
}
|
|
}
|