From dbf570c7111e4d7758b3ada5dbd221f9a45566cd Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:22:53 +0200 Subject: [PATCH] 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 --- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/export_task.rs | 49 +++++++++------------------ crates/ui-egui/src/headless.rs | 6 +++- crates/ui-egui/src/tests_activity.rs | 50 ++++++++++++++++++++++++++++ 12 files changed, 89 insertions(+), 43 deletions(-) diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 55fb5246b..98f5190fe 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 5623785f6..5c6511ed4 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -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" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 7e772de2e..47882a57b 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -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" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 3e0e2ff3b..4d9e7063b 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -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": "コンタクトシートを書き出し中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 0406984a7..84130682f 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -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" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 6f512e405..741515e7e 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -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": "Экспорт контрольного листа" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index d46fbdcd6..ac71e7cf4 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -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": "Експорт аркуша мініатюр" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 59bb0bce2..a879ec47c 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -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": "正在导出联系表" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 9ba3d6cb3..dda65b75a 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -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": "正在匯出聯絡表" } diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 984a94616..b717313ba 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -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>, pub cancel: Arc, rx: Receiver, 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, 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 { 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 { 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(¤t); } 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; diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e2884935a..f58fa7fe0 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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)); } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 74a6ba59e..336ab5eaa 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -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 = 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()); +}