diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 98f5190f..c515ccc9 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2019,5 +2019,6 @@ "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?", - "Exporting contact sheet": "Kontaktabzug wird exportiert" + "Exporting contact sheet": "Kontaktabzug wird exportiert", + "Reading folder": "Ordner wird gelesen" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 5c6511ed..75bb1b6e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1864,5 +1864,6 @@ "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?", - "Exporting contact sheet": "Exportando hoja de contactos" + "Exporting contact sheet": "Exportando hoja de contactos", + "Reading folder": "Leyendo carpeta" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 47882a57..8ae407b2 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1864,5 +1864,6 @@ "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 ?", - "Exporting contact sheet": "Exportation de la planche-contact" + "Exporting contact sheet": "Exportation de la planche-contact", + "Reading folder": "Lecture du dossier" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4d9e7063..800383ec 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1866,5 +1866,6 @@ "Wait for the import to finish": "読み込みが終わるまでお待ちください", "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", - "Exporting contact sheet": "コンタクトシートを書き出し中" + "Exporting contact sheet": "コンタクトシートを書き出し中", + "Reading folder": "フォルダーを読み込み中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 84130682..3be7086e 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1864,5 +1864,6 @@ "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?", - "Exporting contact sheet": "Exportando folha de contato" + "Exporting contact sheet": "Exportando folha de contato", + "Reading folder": "Lendo pasta" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 741515e7..40ea9150 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1577,5 +1577,6 @@ "Wait for the import to finish": "Дождитесь окончания импорта", "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", - "Exporting contact sheet": "Экспорт контрольного листа" + "Exporting contact sheet": "Экспорт контрольного листа", + "Reading folder": "Чтение папки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index ac71e7cf..30671a82 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2058,5 +2058,6 @@ "Wait for the import to finish": "Дочекайтеся завершення імпорту", "Show less": "Показати менше", "Quit while tasks are running?": "Вийти, поки виконуються завдання?", - "Exporting contact sheet": "Експорт аркуша мініатюр" + "Exporting contact sheet": "Експорт аркуша мініатюр", + "Reading folder": "Читання папки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a879ec47..a993a5d0 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "请等待导入完成", "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", - "Exporting contact sheet": "正在导出联系表" + "Exporting contact sheet": "正在导出联系表", + "Reading folder": "正在读取文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index dda65b75..e13a9486 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1864,5 +1864,6 @@ "Wait for the import to finish": "請等待匯入完成", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", - "Exporting contact sheet": "正在匯出聯絡表" + "Exporting contact sheet": "正在匯出聯絡表", + "Reading folder": "正在讀取檔案夾" } diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 4b1e4077..9589554f 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -5,7 +5,8 @@ //! chosen folder, filed by day, by month, into one folder or by a custom folder template, //! optionally renamed), an album (existing or new), a preset and keywords to apply. Importing //! runs on a worker thread (files are probed, copied or moved there) and its batches join the -//! catalog between frames, with a progress window and Cancel; the whole import is one undo step. +//! catalog between frames, with a row and ✕ in the activity stack (issue #345); the whole import is +//! one undo step. use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use lightcraft_engine::import::{ImportCandidate, ScanInput, ScanOutput, ScanProgress, scan_with}; @@ -235,6 +236,8 @@ pub struct ImportTask { /// Auto Import: the selection to keep. keep_selection: Option, run: Option, + /// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag). + guard: Option, } impl ImportTask { @@ -648,7 +651,11 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { task.keep_selection = Some(app.session.selection.clone()); } match started { - Ok(run) => task.run = Some(run), + Ok(run) => { + let label = if task.browse { "Reading folder" } else { "Importing" }; + task.guard = Some(app.session.activity.start("import", label, lightcraft_engine::activity::Cancel::Flag(run.cancel.clone()))); + task.run = Some(run); + } Err(e) => { log::warn!("import: {e}"); app.toast(ctx, crate::i18n::tr_format!("Import failed: {e}", e = e)); @@ -671,6 +678,13 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { task.total = run.total.load(Ordering::Relaxed).max(task.total); task.done = run.done.load(Ordering::Relaxed); } + if let Some(guard) = &task.guard { + guard.progress(task.done as u64, task.total as u64); + // ✕ in the stack (or `activity.cancel`): no further files are started + if guard.is_cancelled() { + task.cancelled = true; + } + } if !finished { app.import = Some(task); return; @@ -812,40 +826,6 @@ fn show_existing(app: &mut LightcraftApp, ctx: &egui::Context, existing: &[u64]) true } -/// The progress window while an import runs, with Cancel (files already copied or added stay; -/// nothing new is started). -pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = &app.import else { return }; - let t = Tokens::get(ctx); - let frac = task.done as f32 / task.total.max(1) as f32; - let text = if task.cancelled { - crate::i18n::tr("Stopping…").to_string() - } else if task.browse { - crate::i18n::tr_format!("Reading photos… {} of {}", task.done, task.total) - } else { - crate::i18n::tr_format!("Adding photos… {} of {}", task.done, task.total) - }; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Importing")) - .title_bar(false) - .resizable(false) - .anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]) - .fixed_size([340.0, 80.0]) - .show(ctx, |ui| { - ui.label(egui::RichText::new(text).color(t.text)); - ui.add(egui::ProgressBar::new(frac).desired_width(320.0)); - let r = ui.add_enabled(!task.cancelled, egui::Button::new(crate::i18n::tr("Cancel"))); - register(ui.ctx(), "button:importCancel", r.rect); - cancel = r.clicked(); - }); - if cancel && let Some(task) = app.import.as_mut() { - task.cancelled = true; - if let Some(run) = &task.run { - run.cancel.store(true, Ordering::Relaxed); - } - } -} - /// The dialog body: options, then the candidate grid. pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { let t = Tokens::get(ui.ctx()); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bae21dac..0e9da3ec 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1072,7 +1072,6 @@ impl LightcraftApp { panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); - import::progress(self, &ctx); sync::progress_window(self, &ctx); import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 336ab5ea..9cdcd8d4 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -211,3 +211,45 @@ fn dead_export_worker_leaves_no_row() { assert!(h.step_until(Duration::from_secs(60), |h| h.app.export.is_none())); assert!(h.app.session.activity.list().is_empty()); } + +/// A folder of `n` stand-in JPEGs and an app whose file probe takes 100 ms each (a slow drive). +fn slow_import(tag: &str, n: usize) -> (Headless, std::path::PathBuf) { + let dir = std::env::temp_dir().join(format!("lc-activity-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for i in 0..n { + std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); + } + let mut s = lightcraft_engine::Session::new(); + s.media.file_probe = Some(std::sync::Arc::new(|p: &str| { + std::thread::sleep(Duration::from_millis(100)); + Ok(lightcraft_engine::media::ProbeInfo { + width: 60, + height: 40, + format: "JPEG".into(), + content_hash: Some(p.to_string()), + ..Default::default() + }) + })); + let mut h = Headless::new(LightcraftApp::new(s, Services { png: None, ..Default::default() }), [1200.0, 800.0], 1.0); + h.step(); + (h, dir) +} + +#[test] +fn import_cancelled_by_command_reports_cancelled() { + let n = 40; + let (mut h, dir) = slow_import("cancel", n); + let undo0 = h.app.session.undo.len(); + crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.as_ref().is_some_and(|t| t.imported >= 2))); + let tasks = h.app.session.activity.list(); + assert_eq!(tasks.first().map(|t| t.kind), Some("import"), "{tasks:?}"); + h.app.session.activity.cancel(tasks[0].id).unwrap(); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.import.is_none())); + assert!(h.app.session.activity.list().is_empty()); + assert!(h.app.ui.toast.as_ref().is_some_and(|t| t.0.starts_with("Import cancelled")), "{:?}", h.app.ui.toast); + assert!(h.app.session.catalog.len() < n, "stopped part-way"); + assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index b9896049..894e1e4e 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -123,7 +123,8 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - let n = 40; + // enough files that the import is still running when its activity row shows (after 0.5 s) + let n = 120; for i in 0..n { std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); } @@ -163,8 +164,11 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { assert!(h.app.import.is_some(), "still importing"); let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); - // Cancel: the files being read finish, nothing new starts - let r = h.request("ui.clickWidget", json!({"id": "button:importCancel"}), Duration::from_secs(10)); + // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts + let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); let t0 = Instant::now(); while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) {