Files
vectorcraft/crates/ui-egui/src/export_progress.rs
T
Fabian GrajalesandClaude Opus 5.5 ec410552cf Export: a progress dialog with Cancel for background exports
While an export runs in the background (Background Export), a dialog shows
what is being exported and how far it has got: artboard by artboard for a
raster export of several artboards, busy otherwise. Cancel (also Esc) stops
it and writes no file; Continue in Background leaves it to the status bar.
It opens after 0.4 s and never over another dialog.

Built on the shared vectorcraft_doc::cancel (#2546): fileio::progress::Progress
wraps its CancelToken and a progress sink, so a cancelled export stops
drawing and keeps no half-drawn pattern tile or effect raster.

- ui: background jobs carry their Progress; export_progress draws the
  dialog; UI commands file.cancelExport and file.exportInBackground.
- i18n: the strings in every language with complete menus, Danish, Dutch
  and Indonesian included; "{n} more exports waiting" is a plural (trn).
- docs: ui-parity, gaps (G12) and ROADMAP rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dt49z1ubK9x1bPq51APgz3
2026-10-18 12:40:20 +07:00

197 lines
8.2 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; 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
//! 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).
fn shown(app: &VectorcraftApp, now: f64, ctx: &egui::Context) -> Option<(String, u64, u64, f32, usize)> {
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;
Some((first.label.clone(), done, total, frac, jobs.len() - 1))
}
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 };
if app.ui.dialog.is_some() {
return;
}
let t = Tokens::get(ctx);
let heading = format!("{}…", crate::i18n::msg(&label));
let mut action = None;
modal::show(ctx, &heading, egui::Id::new("export-progress"), -40.0, 20, |ui| {
ui.set_width(360.0);
modal::heading(ui, &heading);
ui.add_space(12.0);
// 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())]);
// 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 {
ui.horizontal(|ui| {
ui.add(egui::Spinner::new().size(14.0).color(t.accent));
ui.label(egui::RichText::new(tl!("Encoding and writing the file…")).color(t.text_dim));
});
}
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));
}
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() {
action = Some("file.cancelExport");
}
ui.add_space(8.0);
if widgets::secondary_button(ui, "Continue in Background").clicked() {
action = Some("file.exportInBackground");
}
});
});
if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
action = Some("file.cancelExport");
}
if let Some(id) = action
&& let Err(e) = app.run(id, json!({}))
{
app.status(e);
}
}
#[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());
}
}