diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ac5640a4..29ea013e 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -501,7 +501,8 @@ pub struct ScanProgress { /// Files to probe, set once the folders are expanded. pub total: std::sync::atomic::AtomicUsize, pub done: std::sync::atomic::AtomicUsize, - pub cancel: std::sync::atomic::AtomicBool, + /// Shared with the scan's row in the activity stack (its ✕ sets it). + pub cancel: std::sync::Arc, } /// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows. diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c515ccc9..ce6c51aa 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2020,5 +2020,6 @@ "Show less": "Weniger anzeigen", "Quit while tasks are running?": "Beenden, während Aufgaben laufen?", "Exporting contact sheet": "Kontaktabzug wird exportiert", - "Reading folder": "Ordner wird gelesen" + "Reading folder": "Ordner wird gelesen", + "Scanning folder": "Ordner wird durchsucht" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 75bb1b6e..b85268d3 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1865,5 +1865,6 @@ "Show less": "Mostrar menos", "Quit while tasks are running?": "¿Salir con tareas en curso?", "Exporting contact sheet": "Exportando hoja de contactos", - "Reading folder": "Leyendo carpeta" + "Reading folder": "Leyendo carpeta", + "Scanning folder": "Explorando carpeta" } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 8ae407b2..cdbf6ee5 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1865,5 +1865,6 @@ "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", - "Reading folder": "Lecture du dossier" + "Reading folder": "Lecture du dossier", + "Scanning folder": "Analyse du dossier" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 800383ec..60962fa5 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1867,5 +1867,6 @@ "Show less": "表示を減らす", "Quit while tasks are running?": "タスクの実行中に終了しますか?", "Exporting contact sheet": "コンタクトシートを書き出し中", - "Reading folder": "フォルダーを読み込み中" + "Reading folder": "フォルダーを読み込み中", + "Scanning folder": "フォルダーをスキャン中" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 3be7086e..d97e23dd 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1865,5 +1865,6 @@ "Show less": "Mostrar menos", "Quit while tasks are running?": "Sair com tarefas em andamento?", "Exporting contact sheet": "Exportando folha de contato", - "Reading folder": "Lendo pasta" + "Reading folder": "Lendo pasta", + "Scanning folder": "Verificando pasta" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index 40ea9150..f98484d8 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1578,5 +1578,6 @@ "Show less": "Свернуть", "Quit while tasks are running?": "Выйти, пока выполняются задачи?", "Exporting contact sheet": "Экспорт контрольного листа", - "Reading folder": "Чтение папки" + "Reading folder": "Чтение папки", + "Scanning folder": "Сканирование папки" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 30671a82..2c43493f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2059,5 +2059,6 @@ "Show less": "Показати менше", "Quit while tasks are running?": "Вийти, поки виконуються завдання?", "Exporting contact sheet": "Експорт аркуша мініатюр", - "Reading folder": "Читання папки" + "Reading folder": "Читання папки", + "Scanning folder": "Сканування папки" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index a993a5d0..7fe1e57f 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1865,5 +1865,6 @@ "Show less": "收起", "Quit while tasks are running?": "要在任务运行时退出吗?", "Exporting contact sheet": "正在导出联系表", - "Reading folder": "正在读取文件夹" + "Reading folder": "正在读取文件夹", + "Scanning folder": "正在扫描文件夹" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index e13a9486..9cf79141 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1865,5 +1865,6 @@ "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", "Exporting contact sheet": "正在匯出聯絡表", - "Reading folder": "正在讀取檔案夾" + "Reading folder": "正在讀取檔案夾", + "Scanning folder": "正在掃描檔案夾" } diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index f58fa7fe..d0104f7d 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -2438,9 +2438,14 @@ mod tests { assert!(!h.app.scan.as_ref().unwrap().copy, "the running scan keeps its options"); let r = h.request("ui.inspect", json!({}), t); assert!(r["result"]["scan"].is_object(), "{r}"); - let r = h.request("ui.clickWidget", json!({"id": "button:scanCancel"}), t); + // Cancel is the ✕ of the scan's row in the activity stack (shown once it is half a second old) + let id = h.app.session.activity.list().first().map(|t| t.id).expect("a scan row"); + let cross = format!("activity:cancel:{id}"); + assert!(h.step_until(t, |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + let r = h.request("ui.clickWidget", json!({"id": cross}), t); assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.scan.is_none(), "Cancel closes the scan at once"); + h.step(); + assert!(h.app.scan.is_none(), "✕ closes the scan at once"); gate.store(true, std::sync::atomic::Ordering::Relaxed); h.settle(SETTLE); assert!(h.app.ui.dialog.is_none(), "a cancelled scan opens no review"); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index 9589554f..af0a10ad 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -394,6 +394,14 @@ pub struct ScanTask { browse: bool, /// What is being scanned (the review's source). sources: Vec, + /// The scan's row in the activity stack (its ✕ and `activity.cancel` set `progress.cancel`). + guard: lightcraft_engine::activity::TaskGuard, +} + +/// The activity row of a scan: the review's ("Scanning folder") or the Local view's ("Reading folder"). +fn scan_guard(app: &LightcraftApp, progress: &ScanProgress, browse: bool) -> lightcraft_engine::activity::TaskGuard { + let label = if browse { "Reading folder" } else { "Scanning folder" }; + app.session.activity.start("scan", label, lightcraft_engine::activity::Cancel::Flag(progress.cancel.clone())) } /// An import and a Synchronize Folder never run at once: each readies its files against the @@ -419,7 +427,8 @@ pub fn open(app: &mut LightcraftApp, paths: Vec) -> Result) -> std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new() }); + let guard = scan_guard(app, &progress, true); + app.scan = Some(ScanTask { progress, rx, copy: false, browse: true, sources: Vec::new(), guard }); app.renderer.forget_imports(); Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})) } @@ -491,7 +501,18 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { ctx.request_repaint_after(std::time::Duration::from_millis(100)); let out = match task.rx.try_recv() { Ok(o) => o, - Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Empty) => { + if task.guard.is_cancelled() { + // ✕: the worker stops at its next file; don't wait for it (a NAS read can take a while) + app.scan = None; + return; + } + let (total, done) = (task.progress.total.load(Ordering::Relaxed), task.progress.done.load(Ordering::Relaxed)); + task.guard.progress(done as u64, total as u64); + // while the folders are listed the total isn't known: say what is happening instead + task.guard.detail(if total == 0 { crate::i18n::tr("Looking for photos…") } else { "" }); + return; + } Err(_) => { app.scan = None; app.toast(ctx, crate::i18n::tr("Scan failed")); @@ -534,37 +555,6 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { app.ui.dialog = Some(crate::state::Dialog::Import { opts: Box::new(d) }); } -/// The progress window while a folder is being scanned. -pub fn scan_progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = &app.scan else { return }; - let t = Tokens::get(ctx); - let total = task.progress.total.load(Ordering::Relaxed); - let done = task.progress.done.load(Ordering::Relaxed); - let text = if total == 0 { - if task.browse { "Reading folder…" } else { "Looking for photos…" }.to_string() - } else { - crate::i18n::tr_format!("Reading photos… {done} of {total}", done = done, total = total) - }; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Scanning")) - .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(done as f32 / total.max(1) as f32).desired_width(320.0)); - let r = ui.button(crate::i18n::tr("Cancel")); - register(ui.ctx(), "button:scanCancel", r.rect); - cancel = r.clicked(); - }); - if cancel { - // the worker stops at its next file; don't wait for it (a NAS read can take a while) - task.progress.cancel.store(true, Ordering::Relaxed); - app.scan = None; - } -} - impl ScanTask { /// `{done, total}` for `ui.inspect` (total is 0 while the folders are still being listed). pub fn status(&self) -> Value { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0e9da3ec..1b52ce0b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1073,7 +1073,6 @@ impl LightcraftApp { panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); sync::progress_window(self, &ctx); - import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); panels::activity::show(self, &ctx); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 39074161..5e8e967d 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -263,3 +263,22 @@ fn import_cancelled_by_command_reports_cancelled() { assert_eq!(h.app.session.undo.len(), undo0 + 1, "one undo step"); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn scan_shows_a_row_and_cancel_closes_it() { + let (mut h, dir) = slow_import("scan", 120); + crate::import::open(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); + h.step(); + let tasks = h.app.session.activity.list(); + assert_eq!(tasks.first().map(|t| t.kind), Some("scan"), "{tasks:?}"); + assert!(tasks[0].cancellable); + h.app.session.activity.cancel(tasks[0].id).unwrap(); + // like the old Cancel: the scan is dropped at once, without waiting for the file being read + assert!(h.step_until(Duration::from_secs(10), |h| h.app.scan.is_none())); + assert!(h.app.session.activity.list().is_empty()); + for _ in 0..3 { + h.step(); + } + assert!(h.app.ui.dialog.is_none(), "no review opened"); + let _ = std::fs::remove_dir_all(&dir); +}