Issue #345: export progress in the activity stack

A background export registers in the activity stack with the photos done
of the total and the file in progress; its cross and activity.cancel set
the export's own cancel flag. The separate export panel at the bottom
left is gone; the result toast is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Vyrodov
2026-10-10 08:48:58 +02:00
co-authored by Claude Opus 5.5
parent 98f65ff0d7
commit dbf570c711
12 changed files with 89 additions and 43 deletions
+2 -1
View File
@@ -2018,5 +2018,6 @@
"A folder is being synchronized": "Ein Ordner wird gerade synchronisiert",
"Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist",
"Show less": "Weniger anzeigen",
"Quit while tasks are running?": "Beenden, während Aufgaben laufen?"
"Quit while tasks are running?": "Beenden, während Aufgaben laufen?",
"Exporting contact sheet": "Kontaktabzug wird exportiert"
}
+2 -1
View File
@@ -1863,5 +1863,6 @@
"A folder is being synchronized": "Se está sincronizando una carpeta",
"Wait for the import to finish": "Espera a que termine la importación",
"Show less": "Mostrar menos",
"Quit while tasks are running?": "¿Salir con tareas en curso?"
"Quit while tasks are running?": "¿Salir con tareas en curso?",
"Exporting contact sheet": "Exportando hoja de contactos"
}
+2 -1
View File
@@ -1863,5 +1863,6 @@
"A folder is being synchronized": "Un dossier est en cours de synchronisation",
"Wait for the import to finish": "Attendez la fin de l'importation",
"Show less": "Afficher moins",
"Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?"
"Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?",
"Exporting contact sheet": "Exportation de la planche-contact"
}
+2 -1
View File
@@ -1865,5 +1865,6 @@
"A folder is being synchronized": "フォルダを同期中です",
"Wait for the import to finish": "読み込みが終わるまでお待ちください",
"Show less": "表示を減らす",
"Quit while tasks are running?": "タスクの実行中に終了しますか?"
"Quit while tasks are running?": "タスクの実行中に終了しますか?",
"Exporting contact sheet": "コンタクトシートを書き出し中"
}
+2 -1
View File
@@ -1863,5 +1863,6 @@
"A folder is being synchronized": "Uma pasta está sendo sincronizada",
"Wait for the import to finish": "Aguarde a importação terminar",
"Show less": "Mostrar menos",
"Quit while tasks are running?": "Sair com tarefas em andamento?"
"Quit while tasks are running?": "Sair com tarefas em andamento?",
"Exporting contact sheet": "Exportando folha de contato"
}
+2 -1
View File
@@ -1576,5 +1576,6 @@
"A folder is being synchronized": "Идёт синхронизация папки",
"Wait for the import to finish": "Дождитесь окончания импорта",
"Show less": "Свернуть",
"Quit while tasks are running?": "Выйти, пока выполняются задачи?"
"Quit while tasks are running?": "Выйти, пока выполняются задачи?",
"Exporting contact sheet": "Экспорт контрольного листа"
}
+2 -1
View File
@@ -2057,5 +2057,6 @@
"A folder is being synchronized": "Тека синхронізується",
"Wait for the import to finish": "Дочекайтеся завершення імпорту",
"Show less": "Показати менше",
"Quit while tasks are running?": "Вийти, поки виконуються завдання?"
"Quit while tasks are running?": "Вийти, поки виконуються завдання?",
"Exporting contact sheet": "Експорт аркуша мініатюр"
}
+2 -1
View File
@@ -1863,5 +1863,6 @@
"A folder is being synchronized": "正在同步文件夹",
"Wait for the import to finish": "请等待导入完成",
"Show less": "收起",
"Quit while tasks are running?": "要在任务运行时退出吗?"
"Quit while tasks are running?": "要在任务运行时退出吗?",
"Exporting contact sheet": "正在导出联系表"
}
+2 -1
View File
@@ -1863,5 +1863,6 @@
"A folder is being synchronized": "正在同步資料夾",
"Wait for the import to finish": "請等待匯入完成",
"Show less": "收合",
"Quit while tasks are running?": "要在工作執行時結束嗎?"
"Quit while tasks are running?": "要在工作執行時結束嗎?",
"Exporting contact sheet": "正在匯出聯絡表"
}
+16 -33
View File
@@ -1,8 +1,8 @@
//! Background export: the Export dialog, File → Export with Preset and Export with Previous hand
//! their batch to a worker thread so the window stays responsive. Photos are prepared on the UI
//! thread ([`lightcraft_engine::export::prepare_export`]: cheap, needs the session) and rendered,
//! encoded and written on the worker (several side by side: [`run_batch`]); a progress panel shows
//! the count and a Cancel button.
//! encoded and written on the worker (several side by side: [`run_batch`]); its row in the
//! activity stack shows the count, the file in progress and ✕ (issue #345).
//!
//! Needs [`crate::Services::write_shared`] (a thread-safe writer); without it (web) the batch runs
//! synchronously as before.
@@ -11,12 +11,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex};
use egui::Align2;
use lightcraft_engine::activity::{Cancel, TaskGuard};
use lightcraft_engine::export::{Destination, ExportOptions, PreparedExport, run_batch};
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::theme::Tokens;
pub struct ExportTask {
pub total: usize,
@@ -24,6 +23,8 @@ pub struct ExportTask {
pub progress: Arc<Mutex<(usize, String)>>,
pub cancel: Arc<AtomicBool>,
rx: Receiver<Result<Vec<Value>, String>>,
/// The export's row in the activity stack (`activity.cancel` sets `cancel`).
guard: TaskGuard,
}
impl ExportTask {
@@ -59,11 +60,13 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
work();
app.export = Some(ExportTask { total, progress, cancel, rx });
let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
guard.progress(0, total as u64);
app.export = Some(ExportTask { total, progress, cancel, rx, guard });
Ok(json!({"background": true, "total": total}))
}
/// Export a single paginated PDF using the same progress and cancellation UI as image exports.
/// Export a single paginated PDF with the same activity row and cancellation as image exports.
pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> {
if app.export.is_some() {
return Err("an export is already running".into());
@@ -103,11 +106,13 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
std::thread::Builder::new().name("contact-sheet".into()).spawn(work).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
work();
app.export = Some(ExportTask { total, progress, cancel, rx });
let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone()));
row.progress(0, total as u64);
app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
Ok(json!({"background": true, "total": total}))
}
/// Per frame: draw the progress panel; when the batch finishes, report it.
/// Per frame: keep the activity row up to date; when the batch finishes, report it.
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = &app.export else { return };
match task.rx.try_recv() {
@@ -144,30 +149,8 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
let (total, cancel) = (task.total, task.cancel.clone());
let t = Tokens::get(ctx);
egui::Window::new(crate::i18n::tr("Exporting"))
.title_bar(false)
.resizable(false)
.anchor(Align2::LEFT_BOTTOM, [16.0, -56.0])
.fixed_size([300.0, 64.0])
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(crate::i18n::tr_format!("Exporting {} of {total}", (done + 1).min(total), total = total))
.color(t.text),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let stopping = cancel.load(Ordering::Relaxed);
if crate::widgets::text_button(ui, "exportCancel", if stopping { "Stopping…" } else { "Cancel" }, false).clicked() {
cancel.store(true, Ordering::Relaxed);
}
});
});
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(280.0));
ui.label(egui::RichText::new(current).size(11.0).color(t.text_dim));
});
ctx.request_repaint_after(std::time::Duration::from_millis(100));
task.guard.progress(done as u64, task.total as u64);
task.guard.detail(&current);
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
app.export = None;
@@ -214,7 +197,7 @@ mod contact_sheet_tests {
assert_eq!(h.app.pending_picks.len(), 1);
let deadline = Instant::now() + Duration::from_secs(20);
loop {
// The progress window lays out text, which egui only allows inside a frame.
// The activity stack lays out text, which egui only allows inside a frame.
h.step();
if h.app.last_export_result.is_some() {
break;
+5 -1
View File
@@ -1787,6 +1787,8 @@ mod tests {
assert!(h.app.export.is_some(), "running in the background");
let running = h.request("ui.inspect", json!({}), t);
assert_eq!(running["result"]["export"]["running"]["total"], 3, "{}", running["result"]["export"]);
// …and as a row in the activity stack (issue #345)
assert_eq!(running["result"]["activity"][0]["kind"], "export", "{}", running["result"]["activity"]);
let t0 = Instant::now();
while h.app.export.is_some() && t0.elapsed() < Duration::from_secs(60) {
h.step();
@@ -1795,7 +1797,9 @@ mod tests {
let w = written.lock().unwrap().clone();
assert_eq!(w.len(), 3, "{w:?}");
assert!(w.iter().all(|p| p.starts_with("/lc-test-out/")));
let last = h.request("ui.inspect", json!({}), t)["result"]["export"]["last"].clone();
let after = h.request("ui.inspect", json!({}), t)["result"].clone();
assert_eq!(after["activity"], json!([]), "the row is gone");
let last = after["export"]["last"].clone();
assert_eq!(last["files"].as_array().map(Vec::len), Some(3), "{last}");
assert!(last["files"][0]["width"].as_u64().is_some_and(|w| w <= 64));
}
+50
View File
@@ -161,3 +161,53 @@ fn cancelling_task_does_not_block_quit() {
let _f = h.app.session.activity.start("faces", "Finding faces", Cancel::No);
assert!(crate::panels::notices::may_close(&mut h.app));
}
/// A slow disk: every file takes `ms` to write (the UI must not wait for it).
fn slow_writer(ms: u64) -> crate::SharedWrite {
std::sync::Arc::new(move |_: &str, _: &[u8]| {
std::thread::sleep(Duration::from_millis(ms));
Ok(())
})
}
/// Export the first `n` photos in view through the Export dialog (small JPEGs, a made-up folder).
fn start_export(h: &mut Headless, n: usize) {
let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(n).map(|p| p.0).collect();
assert_eq!(ids.len(), n, "the demo library has {n} photos");
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), T);
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T);
h.step();
if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
*full_size = false;
*resize = lightcraft_engine::export::Resize::long_edge(64);
*dir = "/lc-test-out".into();
}
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
assert!(h.app.export.is_some(), "running in the background");
}
#[test]
fn export_shows_a_row_and_stops_on_activity_cancel() {
let mut h = demo();
h.app.services.write_shared = Some(slow_writer(200));
start_export(&mut h, 5);
let tasks = h.request("ui.inspect", json!({}), T)["result"]["activity"].clone();
assert_eq!(tasks[0]["kind"], "export", "{tasks}");
assert_eq!(tasks[0]["total"], 5, "{tasks}");
let id = tasks[0]["id"].as_u64().unwrap();
assert_eq!(h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T)["ok"], true);
assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none()));
assert!(h.app.session.activity.list().is_empty());
assert!(h.app.last_export_result.as_ref().is_some_and(|r| r["cancelled"] == true), "{:?}", h.app.last_export_result);
assert!(!has(&h, "button:exportCancel"), "the old window is gone");
}
#[test]
fn dead_export_worker_leaves_no_row() {
let mut h = demo();
h.app.services.write_shared = Some(std::sync::Arc::new(|_: &str, _: &[u8]| -> Result<(), String> { panic!("synthetic writer panic") }));
start_export(&mut h, 2);
assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none()));
assert!(h.app.session.activity.list().is_empty());
}