mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-27 03:27:14 -06:00
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:
co-authored by
Claude Opus 5.5
parent
98f65ff0d7
commit
dbf570c711
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "コンタクトシートを書き出し中"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Экспорт контрольного листа"
|
||||
}
|
||||
|
||||
@@ -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": "Експорт аркуша мініатюр"
|
||||
}
|
||||
|
||||
@@ -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": "正在导出联系表"
|
||||
}
|
||||
|
||||
@@ -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": "正在匯出聯絡表"
|
||||
}
|
||||
|
||||
@@ -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(¤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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user