diff --git a/CLAUDE.md b/CLAUDE.md index 577458965..814bc5cf8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,6 +55,10 @@ of it. Full standard: `../craftrules/standards/never-crash.md` - **Pure Rust** in the product. No C/C++ dependencies. - **Layering** (`plan/architecture.md` §3, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd. - **Everything is a command** (`crates/engine`): id, label, menu path, shortcut, params, enabled(), run(). UI, CLI, control channel and MCP all dispatch by id. Every slider is a `develop` control spec. +- **Long-running work shows in the activity stack** ([`docs/background-tasks.md`](docs/background-tasks.md)): anything + that runs off the UI thread and can take more than a moment holds a `TaskGuard` from `session.activity.start(kind, + label, cancel)` for its whole run. It adopts the cancel flag the job already checks (`Cancel::Flag`), or uses + `Cancel::No` when the job can't stop. No progress window, panel or progress toast of its own; its tests assert the row. - **Resolution independence:** settings use normalized image coordinates and relative radii; previews and exports must match. - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm). - **Commits:** one task id per commit (`M2.3: local Laplacian highlights/shadows`). Only green states. End messages with the attribution line required by the environment. diff --git a/Cargo.lock b/Cargo.lock index e8037a6f8..0ad4ab843 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3352,6 +3352,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-faces", "lightcraft-geom", + "lightcraft-merge", "lightcraft-meta", "lightcraft-pipeline", "lightcraft-preview", diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs new file mode 100644 index 000000000..654b6db44 --- /dev/null +++ b/crates/engine/src/activity.rs @@ -0,0 +1,391 @@ +//! Background tasks in flight (issue #345): imports, exports, preview builds, downloads, the face scan… Each one +//! registers here while it runs, so every frontend can show them in one place (the desktop app's activity stack) and +//! cancel the ones that can be cancelled (`activity.list`, `activity.cancel`). +//! +//! A job holds a [`TaskGuard`] for as long as it runs; dropping it — when the job ends, fails or its thread unwinds +//! from a panic — removes the row, so a dead worker never leaves a stuck bar. Jobs keep the cancel flag they already +//! check between files or photos: the guard adopts it ([`Cancel::Flag`]), so the stack's ✕, `activity.cancel` and the +//! job's own Cancel all set the same atomic. Numbers are worded by the frontend from `done`, `total` and [`Unit`]; a +//! detail set here is a name (a file, a model), never a sentence, so the frontend can translate everything else. + +use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; + +use serde::Serialize; + +/// The longest label or detail kept, in characters (a file name can be anything). +const MAX_TEXT: usize = 200; + +/// The background tasks in flight. Cheap to clone (one `Arc`), so worker threads can hold it. +#[derive(Clone, Default)] +pub struct Activity { + inner: Arc, +} + +#[derive(Default)] +struct Inner { + /// Oldest first. + tasks: Mutex>>, + next: AtomicU64, +} + +/// How a task can be cancelled. +pub enum Cancel { + /// Not at all (no ✕): the face scan, AI Denoise. + No, + /// With a flag of its own ([`TaskGuard::cancel_flag`]). + Yes, + /// With the job's existing flag. + Flag(Arc), +} + +/// How the frontend words `done` / `total`: "3 of 25", "12 of 340 MB", "40 %". +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Unit { + #[default] + Count, + Bytes, + Percent, +} + +impl Unit { + fn from_u8(v: u8) -> Unit { + match v { + 1 => Unit::Bytes, + 2 => Unit::Percent, + _ => Unit::Count, + } + } + + fn to_u8(self) -> u8 { + match self { + Unit::Count => 0, + Unit::Bytes => 1, + Unit::Percent => 2, + } + } +} + +/// One task as the frontends see it (`activity.list`). +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TaskInfo { + pub id: u64, + /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, + /// `faces`, `denoise`, `findMissing`. + pub kind: &'static str, + /// English; the frontend translates it. + pub label: String, + pub done: u64, + /// 0 while the amount of work isn't known yet (an indeterminate bar). + pub total: u64, + pub unit: Unit, + /// The file, photo or model being worked on, if any. + pub detail: String, + pub cancellable: bool, + /// Cancel was asked for; the job stops at its next check. + pub cancelling: bool, + pub age_ms: u64, +} + +struct Entry { + id: u64, + kind: &'static str, + label: String, + started: web_time::Instant, + done: AtomicU64, + total: AtomicU64, + unit: AtomicU8, + detail: Mutex, + cancel: Option>, + cancellable: AtomicBool, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(PoisonError::into_inner) +} + +fn capped(text: &str) -> String { + text.chars().take(MAX_TEXT).collect() +} + +impl Entry { + fn is_cancellable(&self) -> bool { + self.cancel.is_some() && self.cancellable.load(Ordering::Relaxed) + } + + fn is_cancelled(&self) -> bool { + self.cancel.as_ref().is_some_and(|c| c.load(Ordering::Relaxed)) + } + + fn progress(&self, done: u64, total: u64) { + self.total.store(total, Ordering::Relaxed); + self.done.store(if total > 0 { done.min(total) } else { done }, Ordering::Relaxed); + } + + fn detail(&self, text: &str) { + *lock(&self.detail) = capped(text); + } + + fn info(&self) -> TaskInfo { + TaskInfo { + id: self.id, + kind: self.kind, + label: self.label.clone(), + done: self.done.load(Ordering::Relaxed), + total: self.total.load(Ordering::Relaxed), + unit: Unit::from_u8(self.unit.load(Ordering::Relaxed)), + detail: lock(&self.detail).clone(), + cancellable: self.is_cancellable(), + cancelling: self.is_cancelled(), + age_ms: u64::try_from(self.started.elapsed().as_millis()).unwrap_or(u64::MAX), + } + } +} + +impl Activity { + /// Register a task; it is listed until the returned guard is dropped. `label` is English (the frontend + /// translates it). + pub fn start(&self, kind: &'static str, label: &str, cancel: Cancel) -> TaskGuard { + let id = self.inner.next.fetch_add(1, Ordering::Relaxed).saturating_add(1); + let cancel = match cancel { + Cancel::No => None, + Cancel::Yes => Some(Arc::new(AtomicBool::new(false))), + Cancel::Flag(flag) => Some(flag), + }; + let entry = Arc::new(Entry { + id, + kind, + label: capped(label), + started: web_time::Instant::now(), + done: AtomicU64::new(0), + total: AtomicU64::new(0), + unit: AtomicU8::new(Unit::Count.to_u8()), + detail: Mutex::new(String::new()), + cancellable: AtomicBool::new(cancel.is_some()), + cancel, + }); + lock(&self.inner.tasks).push(entry.clone()); + TaskGuard { activity: self.clone(), entry } + } + + /// Every task in flight, oldest first. + pub fn list(&self) -> Vec { + lock(&self.inner.tasks).iter().map(|e| e.info()).collect() + } + + /// Ask task `id` to stop (it stops at its next check). An unknown id (the task may have just ended) or a task + /// that can't be cancelled is an error. + pub fn cancel(&self, id: u64) -> Result<(), String> { + let tasks = lock(&self.inner.tasks); + let entry = tasks.iter().find(|e| e.id == id).ok_or_else(|| format!("no task {id}"))?; + match (&entry.cancel, entry.is_cancellable()) { + (Some(flag), true) => { + flag.store(true, Ordering::Relaxed); + Ok(()) + } + _ => Err(format!("{} can't be cancelled", entry.label)), + } + } + + /// Ask every cancellable task to stop; returns how many were asked. + pub fn cancel_all(&self) -> usize { + let tasks = lock(&self.inner.tasks); + let mut n = 0; + for e in tasks.iter().filter(|e| e.is_cancellable()) { + if let Some(flag) = &e.cancel { + flag.store(true, Ordering::Relaxed); + n += 1; + } + } + n + } + + /// Tasks that quitting would cut short: cancellable ones not already stopping. + pub fn running_cancellable(&self) -> Vec { + lock(&self.inner.tasks).iter().filter(|e| e.is_cancellable() && !e.is_cancelled()).map(|e| e.info()).collect() + } +} + +/// A task's place in the list, held by whoever owns the job; dropping it removes the row. +pub struct TaskGuard { + activity: Activity, + entry: Arc, +} + +impl TaskGuard { + pub fn id(&self) -> u64 { + self.entry.id + } + + /// `done` of `total` (`total` 0 = not known yet). `done` is kept at most `total`. + pub fn progress(&self, done: u64, total: u64) { + self.entry.progress(done, total); + } + + /// The file, photo or model being worked on (a name, not a sentence). + pub fn detail(&self, text: &str) { + self.entry.detail(text); + } + + pub fn set_unit(&self, unit: Unit) { + self.entry.unit.store(unit.to_u8(), Ordering::Relaxed); + } + + /// A step that must not be cut short (the Lightroom import's commit) turns cancelling off for its duration. + pub fn set_cancellable(&self, yes: bool) { + self.entry.cancellable.store(yes, Ordering::Relaxed); + } + + pub fn cancel_flag(&self) -> Option> { + self.entry.cancel.clone() + } + + pub fn is_cancelled(&self) -> bool { + self.entry.is_cancelled() + } + + /// For progress from another thread; it does not keep the row. + pub fn handle(&self) -> TaskHandle { + TaskHandle { entry: self.entry.clone() } + } +} + +impl Drop for TaskGuard { + fn drop(&mut self) { + let id = self.entry.id; + lock(&self.activity.inner.tasks).retain(|e| e.id != id); + } +} + +impl std::fmt::Debug for TaskGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TaskGuard").field("id", &self.entry.id).field("kind", &self.entry.kind).finish() + } +} + +/// Progress for a task from another thread (see [`TaskGuard::handle`]). +#[derive(Clone)] +pub struct TaskHandle { + entry: Arc, +} + +impl TaskHandle { + pub fn progress(&self, done: u64, total: u64) { + self.entry.progress(done, total); + } + + pub fn detail(&self, text: &str) { + self.entry.detail(text); + } + + pub fn is_cancelled(&self) -> bool { + self.entry.is_cancelled() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + + #[test] + fn guard_drop_removes_the_row() { + let a = Activity::default(); + let g = a.start("export", "Exporting", Cancel::Yes); + assert_eq!(a.list().len(), 1); + drop(g); + assert!(a.list().is_empty()); + } + + #[test] + fn guard_drop_on_panic_unwind_removes_the_row() { + let a = Activity::default(); + let a2 = a.clone(); + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _g = a2.start("import", "Importing", Cancel::Yes); + panic!("synthetic worker panic"); + })); + assert!(r.is_err()); + assert!(a.list().is_empty()); + } + + #[test] + fn cancel_sets_the_adopted_flag() { + let a = Activity::default(); + let flag = Arc::new(AtomicBool::new(false)); + let g = a.start("export", "Exporting", Cancel::Flag(flag.clone())); + a.cancel(g.id()).unwrap(); + assert!(flag.load(Ordering::Relaxed) && g.is_cancelled()); + assert!(a.list()[0].cancelling); + } + + #[test] + fn cancel_errors_instead_of_panicking() { + let a = Activity::default(); + assert!(a.cancel(42).is_err()); + let g = a.start("faces", "Finding faces", Cancel::No); + assert!(a.cancel(g.id()).is_err()); + let h = a.start("lightroom", "Importing Lightroom catalog", Cancel::Yes); + h.set_cancellable(false); + assert!(a.cancel(h.id()).is_err()); + assert!(!h.is_cancelled()); + } + + #[test] + fn cancel_all_counts_only_cancellable() { + let a = Activity::default(); + let (_x, _y, _z) = (a.start("export", "E", Cancel::Yes), a.start("import", "I", Cancel::Yes), a.start("faces", "F", Cancel::No)); + assert_eq!(a.cancel_all(), 2); + assert!(a.running_cancellable().is_empty()); + } + + #[test] + fn progress_is_clamped_and_detail_capped() { + let a = Activity::default(); + let g = a.start("export", "E", Cancel::Yes); + g.progress(12, 10); + g.detail(&"é".repeat(300)); + let t = &a.list()[0]; + assert_eq!((t.done, t.total), (10, 10)); + assert_eq!(t.detail.chars().count(), 200); + g.progress(5, 0); // indeterminate keeps done as given + assert_eq!(a.list()[0].total, 0); + assert_eq!(a.list()[0].unit, Unit::Count); + g.set_unit(Unit::Bytes); + assert_eq!(a.list()[0].unit, Unit::Bytes); + } + + #[test] + fn ids_are_unique_and_list_is_oldest_first() { + let a = Activity::default(); + let (g1, g2) = (a.start("export", "A", Cancel::Yes), a.start("import", "B", Cancel::Yes)); + assert!(g2.id() > g1.id()); + assert_eq!(a.list().iter().map(|t| t.id).collect::>(), vec![g1.id(), g2.id()]); + } + + #[test] + fn poisoned_lock_still_works() { + let a = Activity::default(); + let a2 = a.clone(); + let _ = std::thread::spawn(move || { + let _held = a2.inner.tasks.lock().unwrap(); + panic!("synthetic panic while holding the task list"); + }) + .join(); + assert!(a.inner.tasks.is_poisoned()); + let _g = a.start("export", "E", Cancel::Yes); + assert_eq!(a.list().len(), 1); + } + + #[test] + fn handle_updates_from_another_thread() { + let a = Activity::default(); + let g = a.start("previews", "Building previews", Cancel::Yes); + let h = g.handle(); + std::thread::spawn(move || h.progress(3, 7)).join().unwrap(); + assert_eq!((a.list()[0].done, a.list()[0].total), (3, 7)); + } +} diff --git a/crates/engine/src/cmd/activity.rs b/crates/engine/src/cmd/activity.rs new file mode 100644 index 000000000..af8667728 --- /dev/null +++ b/crates/engine/src/cmd/activity.rs @@ -0,0 +1,65 @@ +//! `activity.list` / `activity.cancel`: the background tasks in flight (see [`crate::activity`]). + +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "activity.list", + "Background Tasks", + [], + None, + "{} → {tasks: [{id, kind, label, done, total, unit: count|bytes|percent, detail, cancellable, cancelling, ageMs}]} — the long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first; total 0 = not known yet", + always, + |s, _| Ok(json!({"tasks": s.activity.list()})) + ), + cmd!( + "activity.cancel", + "Cancel Background Task", + [], + None, + "{id} | {all: true} → {cancelled: n} — ask a task (or every cancellable task) to stop; it stops at its next file or photo. An unknown id (it may have just finished) or a task that can't be cancelled is an error", + always, + |s, p| { + const C: &str = "activity.cancel"; + if p.get("all").and_then(Value::as_bool) == Some(true) { + return Ok(json!({"cancelled": s.activity.cancel_all()})); + } + let id = p.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, "id or all"))?; + s.activity.cancel(id).map_err(|e| bad(C, e))?; + Ok(json!({"cancelled": 1})) + } + ), + ] +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + use crate::activity::Cancel; + + #[test] + fn list_and_cancel_through_execute() { + let mut s = Session::new(); + let g = s.activity.start("export", "Exporting", Cancel::Yes); + let r = s.execute("activity.list", &json!({})).unwrap(); + assert_eq!(r["tasks"][0]["kind"], "export"); + assert_eq!(r["tasks"][0]["cancellable"], true); + let r = s.execute("activity.cancel", &json!({"id": g.id()})).unwrap(); + assert_eq!(r["cancelled"], 1); + assert!(g.is_cancelled()); + } + + #[test] + fn cancel_all_and_errors() { + let mut s = Session::new(); + let (_a, _b) = (s.activity.start("export", "E", Cancel::Yes), s.activity.start("faces", "F", Cancel::No)); + assert_eq!(s.execute("activity.cancel", &json!({"all": true})).unwrap()["cancelled"], 1); + assert!(s.execute("activity.cancel", &json!({"id": 999})).is_err()); + assert!(s.execute("activity.cancel", &json!({})).is_err()); + } +} diff --git a/crates/engine/src/cmd/denoise.rs b/crates/engine/src/cmd/denoise.rs index e1cc0900c..75c6c0a7a 100644 --- a/crates/engine/src/cmd/denoise.rs +++ b/crates/engine/src/cmd/denoise.rs @@ -368,7 +368,7 @@ fn download(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "that model is already installed")); } let host = lightcraft_denoise::known::host(&spec.url).to_string(); - s.denoise.downloads.start(spec, &dir).map_err(|e| bad(C, e))?; + s.denoise.downloads.start(spec, &dir, &s.activity, &k.manifest.name).map_err(|e| bad(C, e))?; Ok(json!({"started": id, "from": host})) } @@ -614,6 +614,7 @@ fn status(s: &mut Session, p: &Value) -> Result { /// `denoise.pump {pace?}`: cheap to call every frame. Takes in what the background job has finished and keeps one going. fn pump(s: &mut Session, p: &Value) -> Result { s.denoise_pump(Pace::parse(p.get("pace").and_then(Value::as_str))); + s.denoise_track(); Ok(json!({ "active": s.denoise.active.is_some(), "running": s.denoise_running_json(), diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 44b359e4a..3a8e401dd 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -356,7 +356,13 @@ fn download(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "that model is already installed")); } let host = spec.host().to_string(); - s.face_downloads.start(spec, &dir).map_err(|e| bad(C, e))?; + let name = known::all() + .into_iter() + .find(|m| m.id == spec.id) + .map(|m| m.name) + .or_else(|| s.face_catalog.entries.iter().find(|e| e.manifest.id == spec.id).map(|e| e.manifest.name.clone())) + .unwrap_or_else(|| spec.id.clone()); + s.face_downloads.start(spec, &dir, &s.activity, &name).map_err(|e| bad(C, e))?; Ok(json!({"started": id, "from": host})) } diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index 9efdb146d..e099f13ec 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -515,6 +515,21 @@ mod imp { })) } + /// The scan's row in the activity stack: there while `pending` photos are left, its whole the most that were left at + /// once since the scan last finished (which it returns; 0 when none are left). It can't be stopped from there: the + /// scan is switched off in Settings. + fn track(s: &mut Session, pending: u64) -> u64 { + if pending == 0 { + s.faces.task = None; + s.faces.peak = 0; + return 0; + } + s.faces.peak = s.faces.peak.max(pending); + let task = s.faces.task.get_or_insert_with(|| s.activity.start("faces", "Finding faces", crate::activity::Cancel::No)); + task.progress(s.faces.peak.saturating_sub(pending), s.faces.peak); + s.faces.peak + } + /// `faces.pump`: cheap to call every frame. Takes in what the background worker has finished and, when it is idle, /// hands it the next photo with faces to embed. Does nothing (and says so) until recognition is switched on in /// Settings with a model chosen. @@ -528,14 +543,17 @@ mod imp { finish_downloads(s); let enabled = s.face_models_dir.clone().is_some_and(|d| enabled_cached(s, &d)); if !enabled { + track(s, 0); return Ok(json!({"active": false})); } if s.faces.retry_at.is_some_and(|t| Instant::now() < t) { + track(s, 0); return Ok(json!({"active": false})); } let Ok(embedder) = current_with(s, C, false) else { // the chosen model is gone or broken: look again in a few seconds, not every frame s.faces.retry_at = Some(Instant::now() + Duration::from_secs(3)); + track(s, 0); return Ok(json!({"active": false})); }; s.faces.retry_at = None; @@ -573,10 +591,13 @@ mod imp { break; } } + let pending = s.faces.queue.len() + s.faces.in_flight.len(); + let peak = track(s, pending as u64); Ok(json!({ "active": true, "inFlight": s.faces.in_flight.len(), - "pendingPhotos": s.faces.queue.len() + s.faces.in_flight.len(), + "pendingPhotos": pending, + "peak": peak, "indexedFaces": s.faces.index.len(), "searchedPhotos": s.faces.scanned.len(), "workers": target, diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index d72996523..24a94cbc8 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -464,7 +464,7 @@ pub fn specs() -> Vec { ), )); } - let started = s.segmenter.start_download().map_err(ai_err)?; + let started = s.segmenter.start_download(&s.activity).map_err(ai_err)?; Ok(json!({"started": started, "installed": s.segmenter.installed(), "downloading": s.segmenter.download_status().running})) } ), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index ebc82862b..e7def13d3 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -4,6 +4,7 @@ //! delete), `album.*`, `develop.*` (settings), `crop.*`, `mask.*`, `preset.*`, `version.*`, //! `edit.*` (undo/redo), and queries (`catalog.query`, `photo.inspect`, `develop.get`…). +mod activity; mod before; pub(crate) mod browse; mod color; @@ -154,6 +155,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(activity::specs()); v }) } diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 3dacc31f2..54f8d0529 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -1,7 +1,7 @@ //! Build previews ahead of time: each photo's grid thumbnail and its loupe view (standard size, //! or 1:1), rendered into the memory + disk preview cache so browsing and the loupe are instant. -//! Runs on a background thread (the app keeps working; progress / cancel by command), or inline -//! with `wait` (CLI, MCP, tests). +//! Runs on a background thread (the app keeps working; progress / cancel by command, and a row in +//! the activity stack, issue #345), or inline with `wait` (CLI, MCP, tests). use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; @@ -9,6 +9,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::activity::{Cancel, TaskGuard}; use crate::media::{RenderJob, THUMB_SIZES}; use crate::{Result, Session}; @@ -21,7 +22,8 @@ pub struct PreviewBuild { pub total: usize, pub done: AtomicUsize, pub failed: AtomicUsize, - pub cancel: AtomicBool, + /// Shared with the build's row in the activity stack (its ✕ sets it). + pub cancel: Arc, pub finished: AtomicBool, /// Damaged smart previews rebuilt (Build Smart Previews only). pub repaired: AtomicUsize, @@ -62,7 +64,19 @@ fn jobs_for(s: &mut Session, id: lightcraft_catalog::PhotoId, edge: Option>, state: &PreviewBuild) { +/// The build's row in the activity stack (`what` as in [`PreviewBuild::what`]). +fn task_for(s: &Session, state: &PreviewBuild) -> TaskGuard { + let (kind, label) = match state.what { + "" => ("previews", "Building previews"), + "smart previews" => ("smartPreviews", "Building smart previews"), + _ => ("smartPreviews", "Discarding smart previews"), + }; + let task = s.activity.start(kind, label, Cancel::Flag(state.cancel.clone())); + task.progress(0, state.total as u64); + task +} + +fn run_all(jobs: Vec>, state: &PreviewBuild, task: &TaskGuard) { for photo_jobs in jobs { if state.cancel.load(Ordering::Relaxed) { break; @@ -71,7 +85,8 @@ fn run_all(jobs: Vec>, state: &PreviewBuild) { if !ok { state.failed.fetch_add(1, Ordering::Relaxed); } - state.done.fetch_add(1, Ordering::Relaxed); + let done = state.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); + task.progress(done as u64, state.total as u64); } state.finished.store(true, Ordering::Relaxed); } @@ -91,17 +106,19 @@ fn build(s: &mut Session, p: &Value) -> Result { let jobs: Vec> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); + let task = task_for(s, &state); let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false) || cfg!(target_arch = "wasm32"); if wait { - run_all(jobs, &state); + run_all(jobs, &state, &task); return Ok(state.json()); } #[cfg(not(target_arch = "wasm32"))] { + // the row stays while the worker runs (it is dropped with the closure, however that ends) let st = state.clone(); std::thread::Builder::new() .name("lc-build-previews".into()) - .spawn(move || run_all(jobs, &st)) + .spawn(move || run_all(jobs, &st, &task)) .map_err(|e| bad(C, format!("could not start: {e}")))?; } Ok(state.json()) @@ -134,7 +151,7 @@ fn smart_run( custom: bool, discard: bool, jobs: Vec, - state: Option<&PreviewBuild>, + state: Option<(&PreviewBuild, &TaskGuard)>, ) -> std::result::Result { let mut n = SmartCounts::default(); if !discard { @@ -146,7 +163,7 @@ fn smart_run( } } for (id, path, source) in jobs { - if state.is_some_and(|st| st.cancel.load(Ordering::Relaxed)) { + if state.is_some_and(|(st, _)| st.cancel.load(Ordering::Relaxed)) { break; } if discard { @@ -173,22 +190,23 @@ fn smart_run( n.built += 1; if damaged { n.repaired += 1; - if let Some(st) = state { + if let Some((st, _)) = state { st.repaired.fetch_add(1, Ordering::Relaxed); } } } Err(e) => { n.failed.push(json!([id.0, e])); - if let Some(st) = state { + if let Some((st, _)) = state { st.failed.fetch_add(1, Ordering::Relaxed); } } } } } - if let Some(st) = state { - st.done.fetch_add(1, Ordering::Relaxed); + if let Some((st, task)) = state { + let done = st.done.fetch_add(1, Ordering::Relaxed).saturating_add(1); + task.progress(done as u64, st.total as u64); } } Ok(n) @@ -222,11 +240,12 @@ fn smart(s: &mut Session, p: &Value) -> Result { let what = if discard { "discarding smart previews" } else { "smart previews" }; let state = Arc::new(PreviewBuild { total: jobs.len(), what, ..Default::default() }); s.preview_build = Some(state.clone()); + let task = task_for(s, &state); let st = state.clone(); std::thread::Builder::new() .name("lc-smart-previews".into()) .spawn(move || { - if let Err(e) = smart_run(&dir, custom, discard, jobs, Some(&st)) { + if let Err(e) = smart_run(&dir, custom, discard, jobs, Some((&st, &task))) { log::warn!("{C}: {e}"); *st.error.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(e); } diff --git a/crates/engine/src/denoise.rs b/crates/engine/src/denoise.rs index 01bcdc784..30df142ca 100644 --- a/crates/engine/src/denoise.rs +++ b/crates/engine/src/denoise.rs @@ -890,6 +890,8 @@ pub(crate) struct State { pub generation: u64, /// Photos made since the session started. made: u64, + /// AI Denoise's row in the activity stack, while a photo is being made or waits its turn. + task: Option, } impl Default for State { @@ -909,6 +911,7 @@ impl Default for State { failed: HashMap::new(), generation: 0, made: 0, + task: None, } } } @@ -1354,6 +1357,31 @@ impl Session { self.denoise.running.is_some() || (!self.denoise.queue.is_empty() && self.denoise.active.is_some()) } + /// Keep AI Denoise's row in the activity stack up to date (each `denoise.pump`): there while a photo is being made or + /// waits its turn, with the running photo's tiles and its file name; gone when the queue is idle. It has no ✕: what is + /// made follows the photos' Denoise amounts and Settings ▸ AI Denoise. + pub(crate) fn denoise_track(&mut self) { + if !self.denoise_busy() { + self.denoise.task = None; + return; + } + let task = self.denoise.task.get_or_insert_with(|| { + let t = self.activity.start("denoise", "AI Denoise", crate::activity::Cancel::No); + t.set_unit(crate::activity::Unit::Percent); + t + }); + match &self.denoise.running { + Some(r) => { + task.progress(r.progress.done.load(Ordering::Relaxed) as u64, r.progress.total.load(Ordering::Relaxed) as u64); + task.detail(self.catalog.photo(r.photo).map_or("", |p| p.file_name.as_str())); + } + None => { + task.progress(0, 0); + task.detail(""); + } + } + } + /// Seconds the running job has been going, and its progress. pub(crate) fn denoise_running_json(&self) -> Value { match &self.denoise.running { diff --git a/crates/engine/src/face_download.rs b/crates/engine/src/face_download.rs index 0c6ed4ee1..df79d5630 100644 --- a/crates/engine/src/face_download.rs +++ b/crates/engine/src/face_download.rs @@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use crate::activity::{Activity, Cancel, TaskHandle, Unit}; + use lightcraft_faces::known::Download; /// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it @@ -42,15 +44,20 @@ pub enum State { struct Shared { state: Mutex, - cancel: AtomicBool, + cancel: Arc, + /// The download's row in the activity stack. + task: Option, } impl Shared { fn new(total: u64) -> Self { - Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) } + Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: Arc::new(AtomicBool::new(false)), task: None } } fn set(&self, s: State) { + if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) { + task.progress(*bytes, *total); + } *self.state.lock().unwrap_or_else(PoisonError::into_inner) = s; } @@ -68,18 +75,27 @@ pub struct Downloads { impl Downloads { /// Start fetching `spec` into `/.downloads/`. Different models can download side by side; the same /// one is started once. - pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> { + /// The activity stack shows it (as `name`) until the thread ends; its ✕ stops it like [`Downloads::discard`]. + pub fn start(&mut self, spec: Download, models_dir: &Path, activity: &Activity, name: &str) -> Result<(), String> { if self.jobs.get(spec.id.as_str()).is_some_and(|j| matches!(j.get(), State::Running { .. })) { return Err("that model is already downloading".into()); } let staging = models_dir.join(STAGING); std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?; - let shared = Arc::new(Shared::new(spec.size_bytes)); + let cancel = Arc::new(AtomicBool::new(false)); + let task = activity.start("download", "Downloading model", Cancel::Flag(cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail(name); + task.progress(0, spec.size_bytes); + let total = spec.size_bytes; + let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) }); let worker = shared.clone(); let id = spec.id.clone(); std::thread::Builder::new() .name("face-model-download".into()) .spawn(move || { + // the row goes when the thread ends, however it ends + let _task = task; let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, spec.base(), &staging, &worker))); if outcome.is_err() { worker.set(State::Failed("the download stopped unexpectedly".into())); @@ -267,6 +283,62 @@ mod tests { base } + /// A local server that sends `body` a few kilobytes at a time: a download that keeps running for a while. + fn serve_slowly(body: Vec) -> String { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/m", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut s in l.incoming().flatten() { + let mut r = BufReader::new(s.try_clone().unwrap()); + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + } + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()); + for chunk in body.chunks(4096) { + if s.write_all(chunk).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + base + } + + /// Poll until `done` holds (or fail after 10 s). + fn wait(what: &str, done: impl Fn() -> bool) { + let t = std::time::Instant::now(); + while !done() { + assert!(t.elapsed() < Duration::from_secs(10), "{what}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_running_download_shows_in_activity_and_cancels() { + use crate::activity::{Activity, Unit}; + let dir = scratch("activity"); + let bytes: Vec = (0..400_000u32).map(|i| (i % 251) as u8).collect(); + let mut spec = spec_for(&bytes); + spec.url = format!("{}/{}", serve_slowly(bytes), spec.file_name); + let activity = Activity::default(); + let mut d = Downloads::default(); + d.start(spec, &dir, &activity, "Test Model").unwrap(); + let rows = activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "Test Model")); + assert_eq!((rows[0].unit, rows[0].total), (Unit::Bytes, 400_000)); + assert!(rows[0].cancellable); + activity.cancel(rows[0].id).unwrap(); + wait("the download never stopped", || !d.running()); + assert_eq!(d.jobs["test-model"].get(), State::Cancelled); + wait("the row stayed", || activity.list().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn fetches_checks_and_stages_a_file() { let dir = scratch("ok"); diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index c250f8683..fd4d9f2e7 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -434,6 +434,10 @@ pub(crate) struct FacesState { pub scanned: Scanned, /// Photos that could not be searched this session (unreadable files): left alone until the next launch. pub scan_failed: std::collections::HashSet, + /// The scan's row in the activity stack, while photos are left. + pub task: Option, + /// The most photos left at once since the scan last finished (the row's whole). + pub peak: u64, } impl FacesState { diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ac5640a4b..29ea013e3 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/engine/src/lib.rs b/crates/engine/src/lib.rs index 22faff70f..c507f0a0a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ #[cfg(not(feature = "denoise"))] extern crate lightcraft_denoise_core as lightcraft_denoise; +pub mod activity; pub mod availability; mod camera_preview; pub mod camera_profiles; @@ -261,6 +262,9 @@ pub struct Session { /// The last Synchronize Folder scan, which `folder.synchronize` acts on while it is current /// (see [`Session::take_folder_changes`]). pub folder_changes: Option, + /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and + /// `activity.list` / `activity.cancel`. + pub activity: activity::Activity, /// The last (or running) Build Previews. pub preview_build: Option>, /// Develop defaults applied on import (persisted in prefs.json). @@ -345,6 +349,7 @@ impl Session { before: Default::default(), import_probes: Default::default(), folder_changes: None, + activity: Default::default(), preview_build: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, diff --git a/crates/engine/src/model_download.rs b/crates/engine/src/model_download.rs index 98707f78f..96f5eb1c7 100644 --- a/crates/engine/src/model_download.rs +++ b/crates/engine/src/model_download.rs @@ -15,6 +15,8 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; +use crate::activity::{Activity, Cancel, TaskHandle, Unit}; + use lightcraft_denoise::known::Download; /// The folder inside the models folder where a downloaded file waits to be installed. Its name starts with a dot, so it @@ -43,15 +45,20 @@ pub enum State { struct Shared { state: Mutex, - cancel: AtomicBool, + cancel: Arc, + /// The download's row in the activity stack. + task: Option, } impl Shared { fn new(total: u64) -> Self { - Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: AtomicBool::new(false) } + Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel: Arc::new(AtomicBool::new(false)), task: None } } fn set(&self, s: State) { + if let (State::Running { bytes, total }, Some(task)) = (&s, &self.task) { + task.progress(*bytes, *total); + } *self.state.lock().unwrap_or_else(PoisonError::into_inner) = s; } @@ -69,18 +76,27 @@ pub struct Downloads { impl Downloads { /// Start fetching `spec` into `/.downloads/`. Different models can download side by side; the same /// one is started once. - pub fn start(&mut self, spec: Download, models_dir: &Path) -> Result<(), String> { + /// The activity stack shows it (as `name`) until the thread ends; its ✕ stops it like [`Downloads::discard`]. + pub fn start(&mut self, spec: Download, models_dir: &Path, activity: &Activity, name: &str) -> Result<(), String> { if self.jobs.get(&spec.id).is_some_and(|j| matches!(j.get(), State::Running { .. })) { return Err("that model is already downloading".into()); } let staging = models_dir.join(STAGING); std::fs::create_dir_all(&staging).map_err(|e| format!("could not create the download folder: {e}"))?; - let shared = Arc::new(Shared::new(spec.size_bytes)); + let cancel = Arc::new(AtomicBool::new(false)); + let task = activity.start("download", "Downloading model", Cancel::Flag(cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail(name); + task.progress(0, spec.size_bytes); + let total = spec.size_bytes; + let shared = Arc::new(Shared { state: Mutex::new(State::Running { bytes: 0, total }), cancel, task: Some(task.handle()) }); let worker = shared.clone(); let id = spec.id.clone(); std::thread::Builder::new() .name("denoise-model-download".into()) .spawn(move || { + // the row goes when the thread ends, however it ends + let _task = task; let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run(&spec, &spec.url, &staging, &worker))); if outcome.is_err() { worker.set(State::Failed("the download stopped unexpectedly".into())); @@ -218,7 +234,7 @@ mod tests { use std::net::TcpListener; fn scratch(name: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("lc-face-dl-{name}-{}", std::process::id())); + let d = std::env::temp_dir().join(format!("lc-denoise-dl-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); d @@ -254,6 +270,62 @@ mod tests { base } + /// A local server that sends `body` a few kilobytes at a time: a download that keeps running for a while. + fn serve_slowly(body: Vec) -> String { + let l = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/m", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut s in l.incoming().flatten() { + let mut r = BufReader::new(s.try_clone().unwrap()); + loop { + let mut line = String::new(); + if r.read_line(&mut line).unwrap_or(0) == 0 || line.trim().is_empty() { + break; + } + } + let _ = s.write_all(format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len()).as_bytes()); + for chunk in body.chunks(4096) { + if s.write_all(chunk).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + base + } + + /// Poll until `done` holds (or fail after 10 s). + fn wait(what: &str, done: impl Fn() -> bool) { + let t = std::time::Instant::now(); + while !done() { + assert!(t.elapsed() < Duration::from_secs(10), "{what}"); + std::thread::sleep(Duration::from_millis(10)); + } + } + + #[test] + fn a_running_download_shows_in_activity_and_cancels() { + use crate::activity::{Activity, Unit}; + let dir = scratch("activity"); + let bytes: Vec = (0..400_000u32).map(|i| (i % 251) as u8).collect(); + let mut spec = spec_for(&bytes); + spec.url = format!("{}/{}", serve_slowly(bytes), spec.file_name); + let activity = Activity::default(); + let mut d = Downloads::default(); + d.start(spec, &dir, &activity, "Test Model").unwrap(); + let rows = activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "Test Model")); + assert_eq!((rows[0].unit, rows[0].total), (Unit::Bytes, 400_000)); + assert!(rows[0].cancellable); + activity.cancel(rows[0].id).unwrap(); + wait("the download never stopped", || !d.running()); + assert_eq!(d.jobs["test-model"].get(), State::Cancelled); + wait("the row stayed", || activity.list().is_empty()); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn fetches_checks_and_stages_a_file() { let dir = scratch("ok"); diff --git a/crates/engine/src/segment/download.rs b/crates/engine/src/segment/download.rs index 875d652f8..7147db09d 100644 --- a/crates/engine/src/segment/download.rs +++ b/crates/engine/src/segment/download.rs @@ -8,6 +8,8 @@ use std::sync::{Arc, Mutex}; use lightcraft_segment::fetch::{self, Options, Progress}; +use crate::activity::{Activity, Cancel, Unit}; + /// What the session sees of a download. #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] pub struct Status { @@ -31,7 +33,7 @@ struct Text { #[derive(Default)] struct Shared { running: AtomicBool, - cancel: AtomicBool, + cancel: Arc, finished: AtomicBool, done: AtomicU64, total: AtomicU64, @@ -92,9 +94,17 @@ impl Downloader { running } - /// Start downloading `files` from `mirrors` into `dir` on a background thread. False when - /// one is already running. - pub fn start(&self, files: &'static [fetch::FileSpec], mirrors: Vec, dir: PathBuf, opts: Options) -> Result { + /// Start downloading `files` from `mirrors` into `dir` on a background thread, shown in the activity stack as + /// `name` (its ✕ cancels like [`Downloader::cancel`]). False when one is already running. + pub fn start( + &self, + files: &'static [fetch::FileSpec], + mirrors: Vec, + dir: PathBuf, + opts: Options, + activity: &Activity, + name: &str, + ) -> Result { let s = self.shared.clone(); if s.running.swap(true, Ordering::SeqCst) { return Ok(false); @@ -106,6 +116,10 @@ impl Downloader { // nothing else runs while `running` was false *lock(&s.text) = Text::default(); *lock(&self.last) = Text::default(); + let task = activity.start("download", "Downloading model", Cancel::Flag(s.cancel.clone())); + task.set_unit(Unit::Bytes); + task.detail(name); + task.progress(0, s.total.load(Ordering::SeqCst)); let guard = Running(s.clone()); let spawned = std::thread::Builder::new().name("sam3-download".into()).spawn(move || { let s = guard.0.clone(); @@ -113,6 +127,7 @@ impl Downloader { let mut on_progress = |p: &Progress| { s.done.store(p.done, Ordering::SeqCst); s.total.store(p.total, Ordering::SeqCst); + task.progress(p.done, p.total); if p.file != last_file { last_file.clone_from(&p.file); lock(&s.text).file.clone_from(&p.file); diff --git a/crates/engine/src/segment/mod.rs b/crates/engine/src/segment/mod.rs index c21873f55..ec2c4ba85 100644 --- a/crates/engine/src/segment/mod.rs +++ b/crates/engine/src/segment/mod.rs @@ -259,7 +259,7 @@ impl Segmenter { /// Start downloading the model (on a background thread; see `segment.model.download`). /// `Ok(false)` when it is installed or downloading already. - pub fn start_download(&self) -> Result { + pub fn start_download(&self, activity: &crate::activity::Activity) -> Result { if !Self::AVAILABLE { return Err("AI masks are not available in this build".into()); } @@ -273,11 +273,11 @@ impl Segmenter { if mirrors.is_empty() { return Err(lightcraft_segment::fetch::no_mirrors_message()); } - self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options()) + self.download.start(lightcraft_segment::fetch::SAM3_FILES, mirrors, dir, lightcraft_segment::fetch::options(), activity, "SAM 3") } #[cfg(not(feature = "sam"))] { - let _ = dir; + let _ = (dir, activity); Ok(false) } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7b0f6cae7..eec58bc46 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -721,6 +721,42 @@ fn build_previews_fills_the_cache() { assert!(s.execute("library.buildPreviews", &json!({"size": "huge"})).is_err()); } +/// Wait (at most a minute) until no background task is listed. +fn wait_activity_empty(s: &Session) { + let t0 = std::time::Instant::now(); + while !s.activity.list().is_empty() { + assert!(t0.elapsed().as_secs() < 60, "tasks still listed: {:?}", s.activity.list()); + std::thread::sleep(std::time::Duration::from_millis(20)); + } +} + +/// Issue #345: a background preview build is a row in the activity stack while it runs. +#[test] +fn preview_build_shows_in_activity_until_it_finishes() { + let mut s = demo(); + let ids: Vec = s.catalog.photos().take(4).map(|p| p.id.0).collect(); + let r = s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let tasks = s.activity.list(); + assert_eq!(tasks.first().map(|t| (t.kind, t.total, t.label.as_str())), Some(("previews", 4, "Building previews")), "{tasks:?} {r}"); + assert!(tasks[0].cancellable); + wait_activity_empty(&s); + assert_eq!(s.execute("library.previewProgress", &json!({})).unwrap()["running"], false); +} + +/// Issue #345: `activity.cancel` stops a preview build like `library.cancelPreviews`. +#[test] +fn activity_cancel_stops_a_preview_build() { + let mut s = demo(); + let ids: Vec = s.catalog.photos().map(|p| p.id.0).collect(); + s.execute("library.buildPreviews", &json!({"ids": ids, "size": "full"})).unwrap(); + let id = s.activity.list().first().map(|t| t.id).expect("a previews row"); + s.execute("activity.cancel", &json!({"id": id})).unwrap(); + wait_activity_empty(&s); + let p = s.execute("library.previewProgress", &json!({})).unwrap(); + assert_eq!(p["cancelled"], true, "{p}"); + assert!(p["done"].as_u64() < p["total"].as_u64(), "stopped part-way: {p}"); +} + /// Colour range: a click samples the colour under it, so the mask selects that colour (white /// in the mask view) and not a different one; ⇧ adds samples, up to five. #[test] diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index ef87747fc..a7d1c7c77 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -757,3 +757,35 @@ fn the_denoise_switch_validates_arguments_and_accepts_explicit_targets_without_s drop(x.s); let _ = std::fs::remove_dir_all(x.dir); } + +#[test] +fn denoise_row_while_queue_is_busy() { + let mut x = setup("activity", true); + let rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "denoise").collect::>(); + let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap(); + assert!(rows(&x.s).is_empty(), "nothing to make, no row: {r}"); + set_amount(&mut x.s, 100.0); + let mut shown = false; + for _ in 0..600 { + let r = x.s.execute("denoise.pump", &json!({"pace": "full"})).unwrap(); + let rows = rows(&x.s); + // (what the pump left running or queued is what the row reflects until the next pump) + if x.s.denoise_busy() { + assert_eq!(rows.len(), 1, "{r}"); + assert_eq!((rows[0].label.as_str(), rows[0].cancellable, rows[0].unit), ("AI Denoise", false, crate::activity::Unit::Percent), "{r}"); + if !r["running"].is_null() { + assert_eq!(rows[0].detail, "ramp.dng", "{r}"); + } + shown = true; + } else { + assert!(rows.is_empty(), "{r}"); + } + if r["ready"].as_u64() >= Some(1) { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(shown, "the photo never showed a row"); + assert!(rows(&x.s).is_empty(), "the row goes once the photo is made"); + let _ = std::fs::remove_dir_all(&x.dir); +} diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index 2fd53c679..aabcb03c3 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -487,3 +487,39 @@ fn every_scanned_face_is_shown_by_the_same_kind_of_box() { assert!((v.x0 - tight.x0).abs() < 1e-3 && (v.y0 - tight.y0).abs() < 1e-3, "{v:?}"); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn face_scan_row_follows_pending_photos() { + let d = temp("activity"); + let (mut s, _) = setup(&d, 64); + // the last two demo photos, not the first two the other scan tests use: two sessions scanning the same demo scene + // at once can deadlock (issue #630), and this test is about the row, not that + let mut ids = s.catalog.photos().map(|p| p.id).collect::>(); + ids.sort(); + let (a, b) = (ids[ids.len() - 2], ids[ids.len() - 1]); + set_regions(&mut s, a, vec![region(0.1, None), region(0.5, None)]); + set_regions(&mut s, b, vec![region(0.3, None)]); + let faces_rows = |s: &Session| s.activity.list().into_iter().filter(|t| t.kind == "faces").collect::>(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let (mut last, mut shown) = (json!(null), false); + while std::time::Instant::now() < deadline { + last = s.execute("faces.pump", &json!({})).unwrap(); + let rows = faces_rows(&s); + if last["pendingPhotos"].as_u64().unwrap_or(0) > 0 { + // while photos are left: one row, not cancellable, its whole the most photos left at once + assert_eq!(rows.len(), 1, "{last}"); + assert_eq!((rows[0].label.as_str(), rows[0].cancellable), ("Finding faces", false)); + assert_eq!(rows[0].total, last["peak"].as_u64().unwrap(), "{last}"); + assert_eq!(rows[0].done, rows[0].total - last["pendingPhotos"].as_u64().unwrap()); + shown = true; + } + if last["active"] == true && last["pendingPhotos"] == 0 && last["indexedFaces"] == 3 { + break; + } + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(shown, "the scan never showed a row: {last}"); + assert_eq!((last["pendingPhotos"].clone(), last["peak"].clone()), (json!(0), json!(0)), "{last}"); + assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done"); + let _ = std::fs::remove_dir_all(&d); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index 784200310..84da187c0 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -497,6 +497,12 @@ fn damaged_smart_previews_are_rebuilt_and_failed_writes_leave_none() { }; assert_eq!((r["done"].as_u64(), r["failed"].as_u64(), r["repaired"].as_u64()), (Some(1), Some(0), Some(1)), "{r}"); assert_eq!(std::fs::read(&file).unwrap(), full, "rebuilt whole in the background"); + // its row in the activity stack (issue #345) goes with it + let t0 = std::time::Instant::now(); + while !s.activity.list().is_empty() && t0.elapsed() < std::time::Duration::from_secs(10) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(s.activity.list().is_empty(), "{:?}", s.activity.list()); let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } diff --git a/crates/engine/src/tests_segment.rs b/crates/engine/src/tests_segment.rs index 59aee18c3..ccca1cb23 100644 --- a/crates/engine/src/tests_segment.rs +++ b/crates/engine/src/tests_segment.rs @@ -230,3 +230,51 @@ fn object_clicks_are_capped() { assert!(!s.segmenter.busy()); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(feature = "sam")] +#[test] +fn a_running_model_download_shows_in_activity_and_cancels() { + use std::io::{BufRead, BufReader, Write}; + // a local mirror that sends its files slowly, so the download is still running when it is cancelled + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/sam3", l.local_addr().unwrap()); + std::thread::spawn(move || { + for mut c in l.incoming().flatten() { + let mut r = BufReader::new(c.try_clone().unwrap()); + let mut line = String::new(); + while r.read_line(&mut line).unwrap_or(0) > 2 { + line.clear(); + } + let _ = c.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1000000\r\nConnection: close\r\n\r\n"); + for _ in 0..250 { + if c.write_all(&[b' '; 4000]).is_err() { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } + } + }); + let dir = tmp("download-activity"); + let mirrors = dir.with_extension("activity-mirrors.txt"); + std::fs::write(&mirrors, format!("# test mirror\n{base}\n")).unwrap(); + let mut s = Session::with_demo(); + s.segmenter.dir = Some(dir.clone()); + s.segmenter.mirrors_file = Some(mirrors.clone()); + assert_eq!(s.execute("segment.model.download", &json!({"acknowledged": true})).unwrap()["started"], true); + let rows = s.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("download", "Downloading model", "SAM 3")); + assert_eq!(rows[0].unit, crate::activity::Unit::Bytes); + assert!(rows[0].cancellable); + s.execute("activity.cancel", &json!({"id": rows[0].id})).unwrap(); + let t = Instant::now(); + while s.segmenter.download_status().running || !s.activity.list().is_empty() { + assert!(t.elapsed() < Duration::from_secs(10), "the download never stopped"); + std::thread::sleep(Duration::from_millis(10)); + } + let e = s.segmenter.download_status().error.unwrap_or_default(); + assert!(e.contains("cancelled"), "{e}"); + assert!(!s.segmenter.installed()); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_file(&mirrors); +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index d67494b78..f7dbc65ab 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -28,6 +28,7 @@ log = { workspace = true } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } lightcraft-faces = { workspace = true } +lightcraft-merge = { workspace = true } [build-dependencies] serde_json = { workspace = true } diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ea00ae3cc..671da17d4 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": "; mit neuen Synonymen: {n}", "Exported {n} keyword{}": "Stichwörter exportiert: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One importiert keine Liste mit ; , < oder > in einem Stichwort: {names}", - " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}" + " — left out, as a keyword list can't hold them: {names}": " — ausgelassen, weil eine Stichwortliste sie nicht aufnehmen kann: {names}", + "{done} of {total}": "{done} von {total}", + "{done} of {total} MB": "{done} von {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} weitere", + "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", + "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c4bded93e..aa24e0f10 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2016,5 +2016,20 @@ "Stopped by an error (see the log)": "Durch einen Fehler angehalten (siehe Protokoll)", "Not saved": "Nicht gespeichert", "A folder is being synchronized": "Ein Ordner wird gerade synchronisiert", - "Wait for the import to finish": "Warten Sie, bis der Import abgeschlossen ist" + "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", + "Reading folder": "Ordner wird gelesen", + "Scanning folder": "Ordner wird durchsucht", + "Building previews": "Vorschauen werden erstellt", + "Building smart previews": "Smart-Vorschauen werden erstellt", + "Discarding smart previews": "Smart-Vorschauen werden verworfen", + "Importing Lightroom catalog": "Lightroom-Katalog wird importiert", + "Reading Lightroom catalog": "Lightroom-Katalog wird gelesen", + "Merging": "Zusammenfügen", + "Downloading model": "Modell wird heruntergeladen", + "Finding faces": "Gesichter werden gesucht", + "Synchronizing folder": "Ordner wird synchronisiert", + "Looking for changes": "Änderungen werden gesucht" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ce6ace8ce..9ba1c8f10 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": "; con sinónimos nuevos: {n}", "Exported {n} keyword{}": "Palabras clave exportadas: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One no importa una lista con ; , < o > en una palabra clave: {names}", - " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}" + " — left out, as a keyword list can't hold them: {names}": " — omitidas, porque una lista de palabras clave no puede contenerlas: {names}", + "{done} of {total}": "{done} de {total}", + "{done} of {total} MB": "{done} de {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} más", + "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", + "{what} merge cancelled": "Combinación {what} cancelada" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index eb13321af..c4f2e8357 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1861,5 +1861,20 @@ "Stopped by an error (see the log)": "Detenido por un error (ver el registro)", "Not saved": "Sin guardar", "A folder is being synchronized": "Se está sincronizando una carpeta", - "Wait for the import to finish": "Espera a que termine la importación" + "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", + "Reading folder": "Leyendo carpeta", + "Scanning folder": "Explorando carpeta", + "Building previews": "Generando vistas previas", + "Building smart previews": "Generando vistas previas inteligentes", + "Discarding smart previews": "Descartando vistas previas inteligentes", + "Importing Lightroom catalog": "Importando catálogo de Lightroom", + "Reading Lightroom catalog": "Leyendo catálogo de Lightroom", + "Merging": "Combinando", + "Downloading model": "Descargando modelo", + "Finding faces": "Buscando caras", + "Synchronizing folder": "Sincronizando carpeta", + "Looking for changes": "Buscando cambios" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 6e6093292..7ccaf5a38 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": " ; avec de nouveaux synonymes : {n}", "Exported {n} keyword{}": "Mots-clés exportés : {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One n’importe pas une liste dont un mot-clé contient ; , < ou > : {names}", - " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}" -} \ No newline at end of file + " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}", + "{done} of {total}": "{done} sur {total}", + "{done} of {total} MB": "{done} sur {total} Mo", + "{pct} %": "{pct} %", + "+{n} more": "+{n} autres", + "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", + "{what} merge cancelled": "Fusion {what} annulée" +} diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index a978e9831..f3422bc0a 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1861,5 +1861,20 @@ "Stopped by an error (see the log)": "Arrêté par une erreur (voir le journal)", "Not saved": "Non enregistré", "A folder is being synchronized": "Un dossier est en cours de synchronisation", - "Wait for the import to finish": "Attendez la fin de l'importation" + "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", + "Reading folder": "Lecture du dossier", + "Scanning folder": "Analyse du dossier", + "Building previews": "Génération des aperçus", + "Building smart previews": "Génération des aperçus dynamiques", + "Discarding smart previews": "Suppression des aperçus dynamiques", + "Importing Lightroom catalog": "Importation du catalogue Lightroom", + "Reading Lightroom catalog": "Lecture du catalogue Lightroom", + "Merging": "Fusion en cours", + "Downloading model": "Téléchargement du modèle", + "Finding faces": "Recherche des visages", + "Synchronizing folder": "Synchronisation du dossier", + "Looking for changes": "Recherche des modifications" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e4976420b..f1c06351c 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": "。{n}件に同義語を追加", "Exported {n} keyword{}": "{n}件のキーワードを書き出しました{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One は ; , < > を含むキーワードがあるリストを読み込みません: {names}", - " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}" + " — left out, as a keyword list can't hold them: {names}": " — キーワードリストに書けないため省略: {names}", + "{done} of {total}": "{total}件中{done}件", + "{done} of {total} MB": "{done} / {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "ほか{n}件", + "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", + "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", + "{what} merge cancelled": "{what}合成をキャンセルしました" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4d9c8ae7f..a7a3a32ef 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1863,5 +1863,20 @@ "Stopped by an error (see the log)": "エラーで停止しました(ログを参照)", "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", - "Wait for the import to finish": "読み込みが終わるまでお待ちください" + "Wait for the import to finish": "読み込みが終わるまでお待ちください", + "Show less": "表示を減らす", + "Quit while tasks are running?": "タスクの実行中に終了しますか?", + "Exporting contact sheet": "コンタクトシートを書き出し中", + "Reading folder": "フォルダーを読み込み中", + "Scanning folder": "フォルダーをスキャン中", + "Building previews": "プレビューを作成中", + "Building smart previews": "スマートプレビューを作成中", + "Discarding smart previews": "スマートプレビューを破棄中", + "Importing Lightroom catalog": "Lightroom カタログを読み込み中", + "Reading Lightroom catalog": "Lightroom カタログを読み取り中", + "Merging": "合成中", + "Downloading model": "モデルをダウンロード中", + "Finding faces": "顔を検索中", + "Synchronizing folder": "フォルダを同期中", + "Looking for changes": "変更を確認中" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index b6c1c23b4..4a9d1168a 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": "; {n} receberam sinônimos", "Exported {n} keyword{}": "Palavras-chave exportadas: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — O Capture One não importa uma lista com ; , < ou > em uma palavra-chave: {names}", - " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}" + " — left out, as a keyword list can't hold them: {names}": " — omitidas, pois uma lista de palavras-chave não pode contê-las: {names}", + "{done} of {total}": "{done} de {total}", + "{done} of {total} MB": "{done} de {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "+{n} mais", + "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", + "{what} merge cancelled": "{what} mesclagem cancelada" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 318120d86..c3d9a8d78 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1861,5 +1861,20 @@ "Stopped by an error (see the log)": "Interrompido por um erro (veja o log)", "Not saved": "Não salvo", "A folder is being synchronized": "Uma pasta está sendo sincronizada", - "Wait for the import to finish": "Aguarde a importação terminar" + "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", + "Reading folder": "Lendo pasta", + "Scanning folder": "Verificando pasta", + "Building previews": "Gerando prévias", + "Building smart previews": "Gerando prévias inteligentes", + "Discarding smart previews": "Descartando prévias inteligentes", + "Importing Lightroom catalog": "Importando catálogo do Lightroom", + "Reading Lightroom catalog": "Lendo catálogo do Lightroom", + "Merging": "Mesclando", + "Downloading model": "Baixando modelo", + "Finding faces": "Procurando rostos", + "Synchronizing folder": "Sincronizando pasta", + "Looking for changes": "Procurando alterações" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index cd55e708f..ba3560ad0 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": "; синонимы добавлены к {n}", "Exported {n} keyword{}": "Экспортировано ключевых слов: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не импортирует список, где в ключевом слове есть ; , < или >: {names}", - " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}" + " — left out, as a keyword list can't hold them: {names}": " — пропущены: список ключевых слов не может их содержать: {names}", + "{done} of {total}": "{done} из {total}", + "{done} of {total} MB": "{done} из {total} МБ", + "{pct} %": "{pct} %", + "+{n} more": "ещё {n}", + "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", + "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", + "{what} merge cancelled": "{what}: слияние отменено" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b51355c74..bf9974740 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1574,5 +1574,21 @@ "Stopped by an error (see the log)": "Остановлено из-за ошибки (см. журнал)", "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", - "Wait for the import to finish": "Дождитесь окончания импорта" + "Wait for the import to finish": "Дождитесь окончания импорта", + "Show less": "Свернуть", + "Quit while tasks are running?": "Выйти, пока выполняются задачи?", + "Exporting contact sheet": "Экспорт контрольного листа", + "Reading folder": "Чтение папки", + "Scanning folder": "Сканирование папки", + "Building previews": "Создание просмотров", + "Building smart previews": "Создание смарт-просмотров", + "Discarding smart previews": "Удаление смарт-просмотров", + "Importing Lightroom catalog": "Импорт каталога Lightroom", + "Reading Lightroom catalog": "Чтение каталога Lightroom", + "Merging": "Слияние", + "Downloading model": "Загрузка модели", + "Finding faces": "Поиск лиц", + "Quit Anyway": "Всё равно выйти", + "Synchronizing folder": "Синхронизация папки", + "Looking for changes": "Поиск изменений" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 2196c6bac..c33dd0cb0 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": "; отримали синоніми: {n}", "Exported {n} keyword{}": "Експортовано ключових слів: {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One не імпортує список, у якому ключове слово містить ; , < або >: {names}", - " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}" + " — left out, as a keyword list can't hold them: {names}": " — пропущено, бо список ключових слів не може їх містити: {names}", + "{done} of {total}": "{done} з {total}", + "{done} of {total} MB": "{done} з {total} МБ", + "{pct} %": "{pct} %", + "+{n} more": "+{n} ще", + "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", + "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", + "{what} merge cancelled": "Об’єднання {what} скасовано" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 52a30fb7c..c73c05b93 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2055,5 +2055,20 @@ "Stopped by an error (see the log)": "Зупинено через помилку (див. журнал)", "Not saved": "Не збережено", "A folder is being synchronized": "Тека синхронізується", - "Wait for the import to finish": "Дочекайтеся завершення імпорту" + "Wait for the import to finish": "Дочекайтеся завершення імпорту", + "Show less": "Показати менше", + "Quit while tasks are running?": "Вийти, поки виконуються завдання?", + "Exporting contact sheet": "Експорт аркуша мініатюр", + "Reading folder": "Читання папки", + "Scanning folder": "Сканування папки", + "Building previews": "Створення попередніх переглядів", + "Building smart previews": "Створення смарт-переглядів", + "Discarding smart previews": "Видалення смарт-переглядів", + "Importing Lightroom catalog": "Імпорт каталогу Lightroom", + "Reading Lightroom catalog": "Читання каталогу Lightroom", + "Merging": "Об’єднання", + "Downloading model": "Завантаження моделі", + "Finding faces": "Пошук облич", + "Synchronizing folder": "Синхронізація папки", + "Looking for changes": "Пошук змін" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e006cdfd9..eb429af42 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": ";{n} 个获得了同义词", "Exported {n} keyword{}": "已导出 {n} 个关键字{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不会导入关键字中含有 ; , < 或 > 的列表:{names}", - " — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}" + " — left out, as a keyword list can't hold them: {names}": " — 已省略,关键字列表无法容纳: {names}", + "{done} of {total}": "{done}/{total}", + "{done} of {total} MB": "{done}/{total} MB", + "{pct} %": "{pct}%", + "+{n} more": "还有 {n} 项", + "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", + "{what} merge cancelled": "已取消 {what} 合并" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d654240bf..21fa4e4e9 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1861,5 +1861,20 @@ "Stopped by an error (see the log)": "因错误而停止(见日志)", "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", - "Wait for the import to finish": "请等待导入完成" + "Wait for the import to finish": "请等待导入完成", + "Show less": "收起", + "Quit while tasks are running?": "要在任务运行时退出吗?", + "Exporting contact sheet": "正在导出联系表", + "Reading folder": "正在读取文件夹", + "Scanning folder": "正在扫描文件夹", + "Building previews": "正在构建预览", + "Building smart previews": "正在构建智能预览", + "Discarding smart previews": "正在丢弃智能预览", + "Importing Lightroom catalog": "正在导入 Lightroom 目录", + "Reading Lightroom catalog": "正在读取 Lightroom 目录", + "Merging": "正在合并", + "Downloading model": "正在下载模型", + "Finding faces": "正在查找人脸", + "Synchronizing folder": "正在同步文件夹", + "Looking for changes": "正在查找更改" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index c47f45691..0b2ec7657 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -221,5 +221,12 @@ "; {n} gained synonyms": ";{n} 個獲得了同義詞", "Exported {n} keyword{}": "已匯出 {n} 個關鍵字{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One 不會匯入關鍵字中含有 ; , < 或 > 的清單:{names}", - " — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}" + " — left out, as a keyword list can't hold them: {names}": " — 已省略,關鍵字清單無法容納: {names}", + "{done} of {total}": "{done}/{total}", + "{done} of {total} MB": "{done}/{total} MB", + "{pct} %": "{pct}%", + "+{n} more": "還有 {n} 項", + "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", + "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", + "{what} merge cancelled": "已取消{what}合併" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ef8160a5c..d2e0a30c2 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1861,5 +1861,20 @@ "Stopped by an error (see the log)": "因錯誤而停止(請見記錄)", "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", - "Wait for the import to finish": "請等待匯入完成" + "Wait for the import to finish": "請等待匯入完成", + "Show less": "收合", + "Quit while tasks are running?": "要在工作執行時結束嗎?", + "Exporting contact sheet": "正在匯出聯絡表", + "Reading folder": "正在讀取檔案夾", + "Scanning folder": "正在掃描檔案夾", + "Building previews": "正在建立預覽", + "Building smart previews": "正在建立智慧型預覽", + "Discarding smart previews": "正在捨棄智慧型預覽", + "Importing Lightroom catalog": "正在匯入 Lightroom 目錄", + "Reading Lightroom catalog": "正在讀取 Lightroom 目錄", + "Merging": "正在合併", + "Downloading model": "正在下載模型", + "Finding faces": "正在尋找臉孔", + "Synchronizing folder": "正在同步資料夾", + "Looking for changes": "正在尋找變更" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1209a6c7a..a8643db1e 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -105,7 +105,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "status": app.ui.status, "copied": app.copied, "notices": app.notices, - "quitPrompt": app.quit_prompt, + "quitPrompt": app.quit_prompt.as_ref().map(crate::QuitPrompt::text), // how hard the background face scan may work right now, and why it is judged so (see `panels::faces::scan_pace`) "faceScan": {"pace": app.caches.faces_pace, "focused": app.caches.faces_in_front, "pending": app.caches.faces_pending, "indexed": app.caches.faces_indexed}, "unsaved": app.session.unsaved().map(|(n, e)| json!({"ops": n, "error": e})), @@ -114,6 +114,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "import": app.import.as_ref().map(crate::import::ImportTask::status), "tasks": app.tasks.labels(), + // every long-running task in flight, as the activity stack shows them (`activity.list`) + "activity": app.session.activity.list(), // commands waiting on a native file dialog shown off the UI thread (`pick`) "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::>(), "memory": memory(app), diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 984a94616..a54d1718f 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; @@ -232,6 +215,26 @@ mod contact_sheet_tests { assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1); } + #[test] + fn contact_sheet_export_shows_a_row_and_its_cross_stops_it() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() }, + ); + let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect(); + start_contact_sheet(&mut app, &json!({"path": "Sheet.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].total), ("export", "Exporting contact sheet", 24)); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + assert!(app.export.as_ref().unwrap().cancel.load(Ordering::Relaxed), "the stack's ✕ stops the sheet"); + let task = app.export.take().unwrap(); + assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err(), "a stopped sheet is not written"); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the export"); + } + #[test] fn cancelled_contact_sheet_never_calls_writer() { let mut app = LightcraftApp::new( diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index e2884935a..d0104f7de 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)); } @@ -2434,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 4b1e40770..af0a10adb 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 { @@ -391,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 @@ -416,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})) } @@ -488,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")); @@ -531,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 { @@ -648,7 +641,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 +668,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 +816,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 50587c86c..6b5033e0c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] @@ -189,6 +191,24 @@ pub struct Perf { pub fps: f64, } +/// Why quitting stopped to ask first (`panels::notices`). +#[derive(Clone, Debug, PartialEq)] +pub enum QuitPrompt { + /// Changes couldn't be written to disk (issue #103). + Unsaved(String), + /// Tasks that quitting would cut short are running: an import, an export… (issue #345). + Tasks(String), +} + +impl QuitPrompt { + /// The prompt's message. + pub fn text(&self) -> &str { + match self { + QuitPrompt::Unsaved(text) | QuitPrompt::Tasks(text) => text, + } + } +} + pub struct LightcraftApp { pub(crate) model_setup: model_setup::Pending, /// Per-catalog-revision caches of library-wide results the panels show every frame @@ -213,8 +233,8 @@ pub struct LightcraftApp { pub headless_host: bool, /// Warnings to show one at a time (damaged settings files…, issue #103). pub notices: Vec, - /// Quitting was stopped because changes couldn't be saved: the prompt's text. - pub quit_prompt: Option, + /// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running. + pub quit_prompt: Option, /// Quit Anyway was chosen: the window may close with unsaved changes. pub quit_confirmed: bool, control_rx: Option>, @@ -302,6 +322,8 @@ pub struct LightcraftApp { /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, + /// The activity stack shows every task, not just the first few ("+N more" was clicked). + pub activity_expanded: bool, } impl LightcraftApp { @@ -367,6 +389,7 @@ impl LightcraftApp { display_applied: None, display_error: None, library_problem: None, + activity_expanded: false, model_setup: Default::default(), } } @@ -454,7 +477,7 @@ impl LightcraftApp { ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval))); } - /// Announce the start and end of a Build Previews run. + /// Announce the end of a Build Previews run. fn preview_build_status(&mut self, ctx: &egui::Context) { use std::sync::atomic::Ordering; let Some(b) = self.session.preview_build.clone() else { return }; @@ -477,14 +500,7 @@ impl LightcraftApp { self.toast(ctx, msg); } } else { - if self.ui.preview_build_seen != Some((key, false)) { - self.ui.preview_build_seen = Some((key, false)); - let msg = match b.what { - "" => crate::i18n::tr_format!("Building previews for {} photos…", b.total), - what => crate::i18n::tr_format!("Working on {what} for {} photos…", b.total, what = crate::i18n::tr(what)), - }; - self.toast(ctx, msg); - } + // The activity stack shows the run while it works; poll for the end. ctx.request_repaint_after(std::time::Duration::from_millis(250)); } } @@ -852,7 +868,8 @@ impl LightcraftApp { let undo0 = app.session.undo.len(); app.import = Some(import::ImportTask::new(paths, params, undo0, false).auto()); }; - if let Err(e) = tasks::spawn(self, LABEL, work, done) { + // listed every few seconds: quiet, no row in the activity stack + if let Err(e) = tasks::spawn(self, LABEL, None, work, done) { log::warn!("{e}"); } } @@ -1049,11 +1066,8 @@ 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); export_task::poll(self, &ctx); + panels::activity::show(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::left::album_drag_feedback(self, &ctx); diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 8cee63e39..28101ed2c 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -8,12 +8,10 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, mpsc}; -use egui::Align2; +use lightcraft_engine::activity::{Cancel, TaskGuard}; use serde_json::{Value, json}; use crate::LightcraftApp; -use crate::theme::Tokens; -use crate::widgets::register; #[derive(Clone, Copy)] enum Kind { @@ -34,7 +32,7 @@ enum Phase { Finalizing, } -/// A Lightroom task in flight. The receiver is polled from the egui owner thread. +/// A Lightroom task in flight. The receiver is polled from the egui owner thread; the activity stack shows it. pub struct LightroomTask { kind: Kind, path: PathBuf, @@ -44,9 +42,53 @@ pub struct LightroomTask { rx: mpsc::Receiver, phase: Phase, cancelled: bool, + guard: TaskGuard, } impl LightroomTask { + #[cfg(not(target_arch = "wasm32"))] + fn new( + s: &lightcraft_engine::Session, + kind: Kind, + path: PathBuf, + cancel: Arc, + total: Arc, + done: Arc, + rx: mpsc::Receiver, + ) -> Self { + let label = match kind { + Kind::Inspect => "Reading Lightroom catalog", + Kind::Import => "Importing Lightroom catalog", + }; + let guard = s.activity.start("lightroom", label, Cancel::Flag(cancel.clone())); + let task = Self { kind, path, cancel, total, done, rx, phase: Phase::Reading, cancelled: false, guard }; + task.refresh(); + task + } + + /// Move to `phase`; only reading the catalog can stop, adding the photos runs to the end. + fn enter(&mut self, phase: Phase) { + self.phase = phase; + self.guard.set_cancellable(phase == Phase::Reading); + self.refresh(); + } + + /// Bring the activity row up to date: the count while reading, then what is being done. + fn refresh(&self) { + let detail = match (self.phase, self.kind) { + (Phase::Reading, Kind::Inspect) => "", + (Phase::Reading, Kind::Import) => crate::i18n::tr("Reading Lightroom catalog…"), + (Phase::Committing, _) => crate::i18n::tr("Adding Lightroom photos…"), + (Phase::Finalizing, _) => crate::i18n::tr("Saving Lightroom import index…"), + }; + if self.phase == Phase::Reading { + self.guard.progress(self.done.load(Ordering::Relaxed) as u64, self.total.load(Ordering::Relaxed) as u64); + } else { + self.guard.progress(0, 0); + } + self.guard.detail(detail); + } + #[cfg(not(target_arch = "wasm32"))] fn status(&self) -> Value { let kind = match self.kind { @@ -236,7 +278,7 @@ fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> let (tx, rx) = mpsc::channel(); spawn_inspect(path.clone(), cancel.clone(), total.clone(), done.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask { kind: Kind::Inspect, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Inspect, path.clone(), cancel, total, done, rx)); Ok(json!({"running": true, "kind": "inspect", "path": path})) } @@ -250,7 +292,7 @@ fn start_import(app: &mut LightcraftApp, path: PathBuf, update_existing: bool, c let (tx, rx) = mpsc::channel(); spawn_prepare(job, cancel.clone(), tx, ctx.clone())?; app.lightroom_last = None; - app.lightroom = Some(LightroomTask { kind: Kind::Import, path: path.clone(), cancel, total, done, rx, phase: Phase::Reading, cancelled: false }); + app.lightroom = Some(LightroomTask::new(&app.session, Kind::Import, path.clone(), cancel, total, done, rx)); Ok(json!({"running": true, "kind": "import", "path": path})) } @@ -296,6 +338,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { let message = match task.rx.try_recv() { Ok(m) => m, Err(mpsc::TryRecvError::Empty) => { + task.refresh(); app.lightroom = Some(task); return; } @@ -337,7 +380,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { ); return; } - task.phase = Phase::Committing; + task.enter(Phase::Committing); let mut finalization = None; let mut report = None; let committed = app.session.execute_fn("library.importLightroom", |s| { @@ -347,7 +390,7 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { Ok(completion.report) }); let report = report.unwrap_or(Value::Null); - task.phase = Phase::Finalizing; + task.enter(Phase::Finalizing); let (tx, rx) = mpsc::channel(); let commit_error = committed.as_ref().err().map(ToString::to_string); let Some(finalization) = finalization else { @@ -409,50 +452,67 @@ pub fn wait_for(_app: &mut LightcraftApp, _ctx: &egui::Context, _timeout: std::t unsupported_wasm() } -/// The progress window for inspect/import, with cancellation. -pub fn progress(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(task) = app.lightroom.as_ref() else { return }; - let total = task.total.load(Ordering::Relaxed); - let done = task.done.load(Ordering::Relaxed); - let title = match task.kind { - Kind::Inspect => crate::i18n::tr("Inspecting Lightroom Catalog"), - Kind::Import => crate::i18n::tr("Importing Lightroom Catalog"), - }; - let text = match task.phase { - Phase::Reading if total == 0 => crate::i18n::tr("Reading Lightroom catalog…").into(), - Phase::Reading => crate::i18n::tr_format!("Reading Lightroom catalog… {done} of {total}", done = done, total = total), - Phase::Committing => crate::i18n::tr("Adding Lightroom photos…").into(), - Phase::Finalizing => crate::i18n::tr("Saving Lightroom import index…").into(), - }; - let mut cancel = false; - let t = Tokens::get(ctx); - egui::Window::new(title).title_bar(false).resizable(false).anchor(Align2::CENTER_BOTTOM, [0.0, -80.0]).fixed_size([360.0, 92.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(340.0)); - let r = - ui.add_enabled(task.phase == Phase::Reading && !task.cancel.load(Ordering::Relaxed), egui::Button::new(crate::i18n::tr("Cancel"))); - register(ui.ctx(), "button:lightroomCancel", r.rect); - cancel = r.clicked(); - }, - ); - if cancel { - if let Some(task) = app.lightroom.as_mut() { - task.cancel(); - } - ctx.request_repaint(); - } - if app.lightroom.is_some() { - ctx.request_repaint_after(std::time::Duration::from_millis(100)); - } -} - #[cfg(test)] mod tests { - use super::inspect_report; + use super::*; use lightcraft_engine::lightroom_catalog::CatalogImport; + /// A task as `start_import` makes it, fed by a channel the test holds instead of a worker. + fn fake_import(h: &crate::headless::Headless) -> (LightroomTask, mpsc::Sender, Arc, Arc, Arc) { + let (tx, rx) = mpsc::channel(); + let (cancel, total, done) = (Arc::new(AtomicBool::new(false)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); + let task = LightroomTask::new(&h.app.session, Kind::Import, "fixture.lrcat".into(), cancel.clone(), total.clone(), done.clone(), rx); + (task, tx, cancel, total, done) + } + + fn demo() -> crate::headless::Headless { + let app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + let mut h = crate::headless::Headless::new(app, [1200.0, 800.0], 1.0); + // no render left running at exit (see tests_activity::demo) + h.settle(std::time::Duration::from_secs(20)); + h + } + + #[test] + fn lightroom_row_is_not_cancellable_while_committing() { + let mut h = demo(); + let (task, tx, cancel, total, done) = fake_import(&h); + h.app.lightroom = Some(task); + total.store(10, Ordering::Relaxed); + done.store(3, Ordering::Relaxed); + h.step(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("lightroom", "Importing Lightroom catalog")); + assert_eq!((rows[0].done, rows[0].total), (3, 10)); + assert!(rows[0].cancellable, "reading the catalog can stop"); + h.app.lightroom.as_mut().unwrap().enter(Phase::Committing); + let row = h.app.session.activity.list().remove(0); + assert!(!row.cancellable, "once photos are being added it runs to the end"); + assert!(h.app.session.activity.cancel(row.id).is_err()); + assert!(!cancel.load(Ordering::Relaxed)); + tx.send(Message::Finalized(Ok(()))).unwrap(); + h.step(); + assert!(h.app.lightroom.is_none()); + assert!(h.app.session.activity.list().is_empty(), "the row goes with the task"); + } + + #[test] + fn lightroom_cancel_from_the_stack_stops_the_read() { + let mut h = demo(); + let (task, tx, cancel, _, _) = fake_import(&h); + h.app.lightroom = Some(task); + h.step(); + let id = h.app.session.activity.list()[0].id; + h.app.session.activity.cancel(id).unwrap(); + assert!(cancel.load(Ordering::Relaxed), "the worker sees the stack's ✕"); + tx.send(Message::Prepared(Err("cancelled".into()))).unwrap(); + h.step(); + assert!(h.app.lightroom.is_none()); + assert_eq!(h.app.lightroom_last, Some(serde_json::json!({"cancelled": true}))); + assert!(h.app.session.activity.list().is_empty()); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2c63a3d30..c9b397f60 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1212,7 +1212,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option>, pub cancel: Arc, rx: Receiver>, + /// The final merge's row in the activity stack (previews have none). + guard: Option, } /// Merge state of the app. @@ -140,7 +143,7 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b std::thread::Builder::new().name("photo-merge".into()).spawn(work).map_err(|e| e.to_string())?; #[cfg(target_arch = "wasm32")] work(); - Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx }) + Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } /// Open the merge dialog for the selection. @@ -169,7 +172,11 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), Err(e) => { app.ui.status = e.to_string(); app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); @@ -273,8 +281,11 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); - let now = ctx.input(|i| i.time); - app.ui.toast = Some((crate::i18n::tr_format!("Merging… {stage} {:.0}%", f * 100.0, stage = stage), now + 0.5, None)); + if let Some(guard) = &t.guard { + let f = if f.is_finite() { f.clamp(0.0, 1.0) } else { 0.0 }; + guard.progress((f * 1000.0) as u64, 1000); + guard.detail(crate::i18n::tr(&stage)); + } ctx.request_repaint_after(std::time::Duration::from_millis(100)); } } diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs new file mode 100644 index 000000000..7b0d449c6 --- /dev/null +++ b/crates/ui-egui/src/panels/activity.rs @@ -0,0 +1,165 @@ +//! The activity stack (issue #345): every long-running task in one place, floating in the top-left corner under the +//! top bar while something runs — a row each with its name, a progress bar, what it is working on, and ✕ for the ones +//! that can be cancelled. The tasks come from the engine's registry ([`lightcraft_engine::activity`]); ✕ runs +//! `activity.cancel` like any other frontend would. + +use egui::{Align, Layout, Margin, Rect, RichText, Sense, Stroke, pos2, vec2}; +use lightcraft_engine::activity::{TaskInfo, Unit}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::i18n::{tr, tr_format}; +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A task shows once it has run this long, so quick ones don't flash. +pub const SHOW_AFTER_MS: u64 = 500; +/// Rows shown before "+N more". +pub const MAX_ROWS: usize = 3; +/// The stack's width (points). +pub const WIDTH: f32 = 280.0; + +const MARGIN: i8 = 8; +const CROSS: f32 = 16.0; + +/// "3 of 25", "12 of 340 MB", "40 %"; empty while the amount of work isn't known. +pub fn count_text(t: &TaskInfo) -> String { + if t.total == 0 { + return String::new(); + } + match t.unit { + Unit::Count => tr_format!("{done} of {total}", done = t.done, total = t.total), + Unit::Bytes => tr_format!("{done} of {total} MB", done = megabytes(t.done), total = megabytes(t.total)), + Unit::Percent => tr_format!("{pct} %", pct = t.done.saturating_mul(100) / t.total), + } +} + +fn megabytes(bytes: u64) -> String { + let mb = bytes as f64 / 1_048_576.0; + if mb < 10.0 { format!("{mb:.1}") } else { format!("{:.0}", mb.floor()) } +} + +/// The row's second line: "Stopping…" once cancelled, else the count and what is being worked on. +fn detail_line(t: &TaskInfo) -> String { + if t.cancelling { + return tr("Stopping…").to_string(); + } + let count = count_text(t); + match (count.is_empty(), t.detail.is_empty()) { + (false, false) => format!("{count} · {}", t.detail), + (false, true) => count, + (true, _) => t.detail.clone(), + } +} + +/// Draw the stack (called every frame, after the panels and dialogs). +pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { + let all = app.session.activity.list(); + if all.is_empty() { + app.activity_expanded = false; + return; + } + // look again soon: a young task becomes due, bars move + ctx.request_repaint_after(std::time::Duration::from_millis(100)); + let tasks: Vec<&TaskInfo> = all.iter().filter(|t| t.age_ms >= SHOW_AFTER_MS).collect(); + if tasks.is_empty() { + return; + } + let t = Tokens::get(ctx); + let shown = if app.activity_expanded { tasks.len() } else { tasks.len().min(MAX_ROWS) }; + let hidden = tasks.len().saturating_sub(shown); + let max_height = (ctx.content_rect().height() - t.top_bar_h - 60.0).max(80.0); + let mut cancel = None; + let mut toggle = false; + let frame = egui::Frame::NONE.fill(t.chrome).stroke(Stroke::new(1.0, t.field_border)).corner_radius(6).inner_margin(Margin::same(MARGIN)); + egui::Area::new(egui::Id::new("activity-stack")).order(egui::Order::Middle).fixed_pos(pos2(8.0, t.top_bar_h + 8.0)).show(ctx, |ui| { + // An area lays out in the size it had last frame, and the scroll area would take that as all the room there + // is: the stack would never grow when rows are added. Give it the whole height below the top bar instead + // (the scroll area keeps to `max_height`). + ui.set_max_height((ctx.content_rect().height() - t.top_bar_h - 16.0).max(80.0)); + frame.show(ui, |ui| { + let inner = WIDTH - 2.0 * (f32::from(MARGIN) + 1.0); + ui.set_width(inner); + ui.spacing_mut().item_spacing.y = 3.0; + egui::ScrollArea::vertical().max_height(max_height).auto_shrink([false, true]).show(ui, |ui| { + for (i, task) in tasks.iter().take(shown).enumerate() { + if i > 0 { + ui.add_space(6.0); + } + if row(ui, &t, task, inner) { + cancel = Some(task.id); + } + } + }); + if hidden > 0 || (app.activity_expanded && tasks.len() > MAX_ROWS) { + ui.add_space(4.0); + let text = if hidden > 0 { tr_format!("+{n} more", n = hidden) } else { tr("Show less").to_string() }; + let r = ui.add(egui::Label::new(RichText::new(text).font(t.font(11.0)).color(t.accent)).sense(Sense::click())); + register(ui.ctx(), "activity:more", r.rect); + toggle = r.clicked(); + } + }); + }); + if let Some(id) = cancel { + self::cancel(app, id); + } + if toggle { + app.activity_expanded = !app.activity_expanded; + } +} + +/// ✕ on task `id`'s row. The task may have just finished or stopped being cancellable: nothing to tell the user then, +/// so the command goes to the engine directly (`LightcraftApp::run` would put its error in the status bar). +pub(crate) fn cancel(app: &mut LightcraftApp, id: u64) { + let _ = app.session.execute("activity.cancel", &json!({"id": id})); +} + +/// One task: name and ✕, the bar, the detail line. True when ✕ was clicked. +fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { + let mut clicked = false; + let r = ui.vertical(|ui| { + ui.set_width(width); + ui.horizontal(|ui| { + let cross = task.cancellable && !task.cancelling; + let label_w = if cross { width - CROSS - 6.0 } else { width }; + ui.allocate_ui_with_layout(vec2(label_w, 18.0), Layout::left_to_right(Align::Center), |ui| { + // the whole width, so the cross lands at the right edge however short the name + ui.set_width(label_w); + ui.add(egui::Label::new(RichText::new(tr(&task.label)).font(t.font(12.5)).color(t.text)).truncate()); + }); + if cross { + let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); + register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); + clicked = resp.on_hover_text(tr("Cancel")).clicked(); + } + }); + bar(ui, t, task, width); + let line = detail_line(task); + if !line.is_empty() { + ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate()); + } + }); + register(ui.ctx(), format!("activity:row:{}", task.id), r.response.rect); + clicked +} + +/// The progress bar; while the amount of work isn't known, a segment sweeps across. +fn bar(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) { + let (rect, _) = ui.allocate_exact_size(vec2(width, 5.0), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, 2.5, t.field); + let fill = if task.total > 0 { + let frac = (task.done as f64 / task.total as f64).clamp(0.0, 1.0) as f32; + Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height())) + } else { + let phase = (ui.input(|i| i.time) * 0.7).fract() as f32; + let w = rect.width() * 0.3; + let x = rect.left() + (rect.width() + w) * phase - w; + Rect::from_min_max(pos2(x.max(rect.left()), rect.top()), pos2((x + w).min(rect.right()), rect.bottom())) + }; + if fill.width() > 0.0 { + p.rect_filled(fill, 2.5, if task.cancelling { t.text_dim } else { t.accent }); + } +} diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 3117eb607..13b4b1329 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -669,8 +669,9 @@ pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { app.caches.faces_active = v["active"] == true; app.caches.faces_indexed = v["indexedFaces"].as_u64().unwrap_or(0); app.caches.faces_pending = v["pendingPhotos"].as_u64().unwrap_or(0); - // the progress bar's whole: the most photos that were left at once since the scan last finished - app.caches.faces_peak = if app.caches.faces_pending == 0 { 0 } else { app.caches.faces_peak.max(app.caches.faces_pending) }; + // the progress bar's whole: the most photos that were left at once since the scan last finished (the engine keeps + // it, so Settings and the activity stack agree) + app.caches.faces_peak = v["peak"].as_u64().unwrap_or(0); // Frames are drawn only when something asks for one, so nothing here wakes the window needlessly: while there is work it // asks to be called again in 50 ms (the progress in Settings, the next photos for the workers); with recognition on and // nothing to do it looks again every few seconds (a few wake-ups a minute); with recognition off it asks for nothing diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d87d6789e..a8d99aed2 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Window regions and panels. +pub mod activity; pub mod bottombar; pub mod chips; pub mod compare; diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 62be7fe30..619efe1d0 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -1,12 +1,12 @@ //! Things the user must not miss (issue #103): settings files that were damaged or unreadable when //! the library opened (and host warnings such as a damaged `ui.json`), and quitting while changes -//! are only in memory. +//! are only in memory or while tasks that would be cut short are running (issue #345). use egui::{Align2, RichText, vec2}; -use crate::LightcraftApp; use crate::theme::Tokens; use crate::widgets::register; +use crate::{LightcraftApp, QuitPrompt}; /// Collect the library's settings-file warnings (each is shown once). pub fn logic(app: &mut LightcraftApp) { @@ -14,10 +14,25 @@ pub fn logic(app: &mut LightcraftApp) { app.notices.extend(new); } -/// The window may close now (`true`), or the quit prompt is shown (`false`): changes that -/// couldn't be written are retried first — the log, then a snapshot of everything in memory. +/// The window may close now (`true`), or the quit prompt is shown (`false`). Running tasks that +/// can be cancelled (an import, an export…) are asked about first; then changes that couldn't be +/// written are retried — the log, then a snapshot of everything in memory. pub fn may_close(app: &mut LightcraftApp) -> bool { - if app.quit_confirmed || app.session.unsaved().is_none() { + if app.quit_confirmed { + return true; + } + // before the library is touched: a Cancel here keeps working with it + let running = app.session.activity.running_cancellable(); + if !running.is_empty() { + let names = running.iter().map(|t| crate::i18n::tr(&t.label)).collect::>().join(", "); + app.quit_prompt = Some(QuitPrompt::Tasks(if running.len() == 1 { + crate::i18n::tr_format!("1 task is still running: {names}. Quitting stops it.", names = names) + } else { + crate::i18n::tr_format!("{n} tasks are still running: {names}. Quitting stops them.", n = running.len(), names = names) + })); + return false; + } + if app.session.unsaved().is_none() { return true; } let _ = app.session.persist(); @@ -29,13 +44,13 @@ pub fn may_close(app: &mut LightcraftApp) -> bool { match app.session.unsaved() { None => true, Some((n, e)) => { - app.quit_prompt = Some(crate::i18n::tr_format!( + app.quit_prompt = Some(QuitPrompt::Unsaved(crate::i18n::tr_format!( "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.", if n == 1 { "" } else { "s" }, if n == 1 { "it is" } else { "they are" }, n = n, e = e - )); + ))); false } } @@ -85,7 +100,20 @@ fn window(ctx: &egui::Context, id: &str, title: &str, text: &str, buttons: &[(&s } pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { - if let Some(text) = app.quit_prompt.clone() { + if let Some(QuitPrompt::Tasks(text)) = app.quit_prompt.clone() { + let buttons = [("quitAnyway", "Quit Anyway", Choice::QuitAnyway), ("quitCancel", "Cancel", Choice::Cancel)]; + let Some(i) = window(ctx, "quit-tasks", "Quit while tasks are running?", &text, &buttons) else { return }; + app.quit_prompt = None; + if matches!(buttons[i].2, Choice::QuitAnyway) { + app.session.activity.cancel_all(); + // unsaved changes still get their own question + if may_close(app) { + app.ui.quit = true; + } + } + return; + } + if let Some(QuitPrompt::Unsaved(text)) = app.quit_prompt.clone() { let buttons = [ ("quitRetry", "Try Saving Again", Choice::Retry), ("quitAnyway", "Quit Anyway", Choice::QuitAnyway), diff --git a/crates/ui-egui/src/sync.rs b/crates/ui-egui/src/sync.rs index 244f4308c..cd4a267af 100644 --- a/crates/ui-egui/src/sync.rs +++ b/crates/ui-egui/src/sync.rs @@ -1,11 +1,13 @@ //! Synchronize Folder in the app: the folder is scanned on a worker thread (a network share can //! take minutes; no frame waits for it) while its dialog is open, and the dialog then says what //! changed and what to do about it. Synchronize runs `folder.synchronize`, which acts on exactly -//! the scan the dialog showed (see `lightcraft_engine::sync`). +//! the scan the dialog showed (see `lightcraft_engine::sync`). Both the scan and the run show a row in the activity +//! stack (issue #345); its ✕ stops them like the dialog's Cancel. use std::sync::Arc; use std::sync::atomic::Ordering; +use lightcraft_engine::activity::{Cancel, TaskGuard}; use lightcraft_engine::sync::{FolderChanges, SyncChoice, SyncCommit, SyncInput, SyncJob, SyncProgress, SyncStep, scan_with}; use serde_json::{Value, json}; @@ -18,6 +20,8 @@ pub struct SyncTask { path: String, progress: Arc, rx: std::sync::mpsc::Receiver, + /// Its row in the activity stack; ✕ cancels the scan and closes the dialog. + guard: TaskGuard, } /// Open the dialog for folder `path` (called `name` in it) and start scanning. @@ -34,7 +38,9 @@ pub fn open(app: &mut LightcraftApp, path: &str, name: &str, disk: bool) -> Resu std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.sync = Some(SyncTask { path: path.to_string(), progress, rx }); + let guard = app.session.activity.start("sync", "Looking for changes", Cancel::Flag(progress.files.cancel.clone())); + guard.detail(name); + app.sync = Some(SyncTask { path: path.to_string(), progress, rx, guard }); let d = SyncChoice::default(); app.ui.dialog = Some(Dialog::SynchronizeFolder { path: path.to_string(), @@ -78,6 +84,14 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { cancel(app); return; } + if task.guard.is_cancelled() { + // ✕ on its row: a scan stopped part-way has nothing to show + cancel(app); + app.ui.dialog = None; + return; + } + let (done, total) = task.progress.counts(); + task.guard.progress(done as u64, total as u64); ctx.request_repaint_after(std::time::Duration::from_millis(100)); let changes = match task.rx.try_recv() { Ok(c) => c, @@ -210,7 +224,9 @@ pub fn confirm(app: &mut LightcraftApp, dlg: &Dialog) -> Result { std::thread::spawn(job); #[cfg(target_arch = "wasm32")] job(); - app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new() }); + let guard = app.session.activity.start("sync", "Synchronizing folder", Cancel::Flag(progress.files.cancel.clone())); + guard.detail(name); + app.sync_run = Some(SyncRun { name: name.clone(), progress, crashed, rx, commit: Some(commit), stopping: false, not_saved: Vec::new(), guard }); Ok(json!({"started": true})) } @@ -227,6 +243,8 @@ pub struct SyncRun { commit: Option, /// Cancel was pressed: nothing new is started; what was readied is still committed. stopping: bool, + /// Its row in the activity stack (its ✕ is Cancel). + guard: TaskGuard, } impl SyncRun { @@ -253,6 +271,11 @@ impl Drop for SyncRun { pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(run) = app.sync_run.as_mut() else { return }; ctx.request_repaint_after(std::time::Duration::from_millis(100)); + if run.guard.is_cancelled() { + run.stopping = true; + } + let (done, total) = run.counts(); + run.guard.progress(done as u64, total as u64); let mut steps = Vec::new(); let finished = loop { match run.rx.try_recv() { @@ -317,32 +340,3 @@ pub fn poll_run(app: &mut LightcraftApp, ctx: &egui::Context) { app.toast_for(ctx, format!("{name}: {text}"), 12.0); } } - -/// The progress window while a synchronize runs, with Cancel (what was done stays, as one undo -/// step; nothing new is started). -pub fn progress_window(app: &mut LightcraftApp, ctx: &egui::Context) { - let Some(run) = &app.sync_run else { return }; - let t = Tokens::get(ctx); - let (done, total) = run.counts(); - let text = - if run.stopping { crate::i18n::tr("Stopping…").to_string() } else { format!("{} {done} / {total}", crate::i18n::tr("Synchronizing…")) }; - let frac = if total == 0 { 0.0 } else { done as f32 / total as f32 }; - let stopping = run.stopping; - let mut cancel = false; - egui::Window::new(crate::i18n::tr("Synchronize Folder")) - .id(egui::Id::new("sync-progress")) - .title_bar(false) - .resizable(false) - .anchor(egui::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(!stopping, egui::Button::new(crate::i18n::tr("Cancel"))); - crate::widgets::register(ui.ctx(), "button:syncCancel", r.rect); - cancel = r.clicked(); - }); - if cancel && let Some(run) = app.sync_run.as_mut() { - run.cancel(); - } -} diff --git a/crates/ui-egui/src/tasks.rs b/crates/ui-egui/src/tasks.rs index 7e0d053e6..fb5a1f031 100644 --- a/crates/ui-egui/src/tasks.rs +++ b/crates/ui-egui/src/tasks.rs @@ -1,7 +1,8 @@ //! Background tasks for commands whose file-system work can take long on a slow or offline drive //! (Find Missing Photos walking a folder, listing the auto-import folder…): the work runs on a //! worker thread and its result is applied on the UI thread between frames, so the window keeps -//! answering. Imports, scans, exports and preview builds have their own tasks with progress. +//! answering. Imports, scans, exports and preview builds have their own tasks with progress. A task the user started +//! (Find Missing Photos) shows in the activity stack while it runs; quiet ones (the auto-import listing) don't. use std::sync::mpsc::{Receiver, TryRecvError, channel}; @@ -13,6 +14,8 @@ type Finish = Box; struct Task { label: String, rx: Receiver, + /// Its row in the activity stack (none for quiet tasks); goes with the task. + _guard: Option, } /// The background tasks in flight. @@ -39,10 +42,12 @@ impl Tasks { } /// Run `work` on a worker thread, then `done(app, ctx, result)` on the UI thread (in the browser -/// build, which has no threads, `work` runs at once and `done` on the next frame). +/// build, which has no threads, `work` runs at once and `done` on the next frame). With `kind`, the +/// activity stack shows `label` meanwhile, without a count and without ✕ (the work can't be stopped). pub fn spawn( app: &mut LightcraftApp, label: &str, + kind: Option<&'static str>, work: impl FnOnce() -> T + Send + 'static, done: impl FnOnce(&mut LightcraftApp, &egui::Context, T) + Send + 'static, ) -> Result<(), String> { @@ -59,7 +64,8 @@ pub fn spawn( std::thread::Builder::new().name(format!("lc-task-{label}")).spawn(job).map_err(|e| format!("{label}: could not start: {e}"))?; #[cfg(target_arch = "wasm32")] job(); - app.tasks.running.push(Task { label: label.to_string(), rx }); + let guard = kind.map(|k| app.session.activity.start(k, label, lightcraft_engine::activity::Cancel::No)); + app.tasks.running.push(Task { label: label.to_string(), rx, _guard: guard }); Ok(()) } diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs new file mode 100644 index 000000000..ca2a6de7e --- /dev/null +++ b/crates/ui-egui/src/tests_activity.rs @@ -0,0 +1,395 @@ +//! Issue #345: the activity stack — one place, top-left, for every long-running task, with ✕ to cancel. + +use std::time::Duration; + +use lightcraft_engine::activity::{Cancel, TaskInfo, Unit}; +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::activity::{WIDTH, count_text}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // let the first renders finish: a render still running when the test binary exits can crash in the + // GPU driver's teardown (the preview pool doesn't join its workers) + h.settle(T); + h +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn rows(h: &Headless) -> usize { + h.app.widgets.iter().filter(|(w, _)| w.starts_with("activity:row:")).count() +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +/// Rows show once their task is half a second old. +fn wait_visible(h: &mut Headless) { + std::thread::sleep(Duration::from_millis(600)); + h.step(); + h.step(); +} + +/// Click a widget and let the synthetic events (move, press, release) play out. +fn click(h: &mut Headless, id: &str) { + h.step(); + h.step(); + assert_eq!(h.request("ui.clickWidget", json!({"id": id}), T)["ok"], true, "{id}"); + for _ in 0..4 { + h.step(); + } +} + +#[test] +fn rows_appear_after_half_a_second_and_cancel_by_click() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + g.progress(2, 5); + h.step(); + // a frame on a loaded machine can outlast the half second: only judge a task still young after it + if h.app.session.activity.list()[0].age_ms < crate::panels::activity::SHOW_AFTER_MS { + assert!(!has(&h, &format!("activity:row:{}", g.id())), "not before 0.5 s"); + } + wait_visible(&mut h); + assert!(has(&h, &format!("activity:row:{}", g.id()))); + click(&mut h, &format!("activity:cancel:{}", g.id())); + assert!(g.is_cancelled()); + let inspect = h.request("ui.inspect", json!({}), T); + assert_eq!(inspect["result"]["activity"][0]["cancelling"], true, "{}", inspect["result"]["activity"]); +} + +#[test] +fn more_than_three_rows_overflow_and_expand() { + let mut h = demo(); + let guards: Vec<_> = (0..5).map(|i| h.app.session.activity.start("export", &format!("Task {i}"), Cancel::Yes)).collect(); + wait_visible(&mut h); + assert_eq!(rows(&h), 3); + assert!(has(&h, "activity:more")); + click(&mut h, "activity:more"); + assert_eq!(rows(&h), 5); + // expanded, the stack grows to show them all + let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn"); + for g in &guards { + let r = rect(&h, &format!("activity:row:{}", g.id())); + assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); + } + drop(guards); + h.step(); + assert_eq!(rows(&h), 0); +} + +#[test] +fn not_cancellable_rows_have_no_cross() { + let mut h = demo(); + let g = h.app.session.activity.start("faces", "Finding faces", Cancel::No); + wait_visible(&mut h); + assert!(has(&h, &format!("activity:row:{}", g.id()))); + assert!(!has(&h, &format!("activity:cancel:{}", g.id()))); +} + +#[test] +fn count_text_by_unit() { + let mut t = TaskInfo { + id: 1, + kind: "export", + label: "Exporting".into(), + done: 3, + total: 25, + unit: Unit::Count, + detail: String::new(), + cancellable: true, + cancelling: false, + age_ms: 600, + }; + assert_eq!(count_text(&t), "3 of 25"); + (t.done, t.total, t.unit) = (12 * 1_048_576, 340 * 1_048_576, Unit::Bytes); + assert_eq!(count_text(&t), "12 of 340 MB"); + (t.done, t.total, t.unit) = (400, 1000, Unit::Percent); + assert_eq!(count_text(&t), "40 %"); + t.total = 0; + assert_eq!(count_text(&t), ""); +} + +#[test] +fn stale_cancel_is_harmless() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + wait_visible(&mut h); + let id = g.id(); + drop(g); + let r = h.request("engine.execute", json!({"command": "activity.cancel", "params": {"id": id}}), T); + assert_eq!(r["ok"], false, "an error result, not a panic: {r}"); + h.step(); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); + // the same through the row's ✕ (clicked in the frame the task ended): no message in the status bar either + h.app.ui.status = "Ready".into(); + crate::panels::activity::cancel(&mut h.app, id); + h.step(); + assert_eq!(h.app.ui.status, "Ready"); + assert!(h.app.ui.toast.is_none(), "{:?}", h.app.ui.toast); +} + +#[test] +fn long_detail_stays_inside_the_stack() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exportieren", Cancel::Yes); + g.detail(&"IMG_".repeat(75)); + wait_visible(&mut h); + let row = rect(&h, &format!("activity:row:{}", g.id())); + assert!(row.width() <= WIDTH + 1.0, "{row:?}"); +} + +#[test] +fn cross_sits_at_the_right_edge_of_its_row() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + wait_visible(&mut h); + let row = rect(&h, &format!("activity:row:{}", g.id())); + let cross = rect(&h, &format!("activity:cancel:{}", g.id())); + assert!(cross.right() >= row.right() - 2.0, "the cross is at the right, not after the label: {cross:?} in {row:?}"); +} + +#[test] +fn quit_with_a_running_task_asks_and_quit_anyway_cancels() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + wait_visible(&mut h); + assert!(!crate::panels::notices::may_close(&mut h.app)); + assert!(matches!(h.app.quit_prompt, Some(crate::QuitPrompt::Tasks(_))), "{:?}", h.app.quit_prompt); + h.step(); + assert!(!has(&h, "button:quitRetry")); + click(&mut h, "button:quitAnyway"); + assert!(g.is_cancelled() && h.quit_requested()); +} + +#[test] +fn quit_cancel_keeps_the_task_running() { + let mut h = demo(); + let g = h.app.session.activity.start("import", "Importing", Cancel::Yes); + assert!(!crate::panels::notices::may_close(&mut h.app)); + click(&mut h, "button:quitCancel"); + assert!(!g.is_cancelled() && h.app.quit_prompt.is_none() && !h.app.quit_confirmed); +} + +#[test] +fn cancelling_task_does_not_block_quit() { + let mut h = demo(); + let g = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + h.app.session.activity.cancel(g.id()).unwrap(); + 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}"); +} + +#[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); + assert!(h.app.export.is_some(), "running in the background"); + 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()); +} + +/// 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.settle(T); + (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); +} + +#[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); +} + +/// A library of three bracketed DNGs (−2, 0, +2 EV) on disk, all selected. +fn bracket(tag: &str) -> (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(); + let mut paths = Vec::new(); + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &[-2.0, 0.0, 2.0]).unwrap().into_iter().enumerate() { + let p = dir.join(format!("IMG_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(T); + (h, dir) +} + +#[test] +fn final_merge_shows_a_row_and_cancels() { + let (mut h, dir) = bracket("merge"); + let before = h.app.session.catalog.len(); + crate::merge::start_last(&mut h.app, "merge.hdr").unwrap(); + h.step(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].unit), ("merge", "Merging", Unit::Percent)); + assert!(rows[0].cancellable); + assert_eq!(rows[0].total, 1000); + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + assert!(h.app.merge.last_result.is_none(), "nothing merged"); + assert_eq!(h.app.session.catalog.len(), before, "no photo added"); + assert!(h.app.session.activity.list().is_empty()); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert_eq!(toast, "HDR merge cancelled"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn find_missing_shows_an_indeterminate_row() { + let mut h = demo(); + let dir = std::env::temp_dir().join(format!("lc-activity-findmissing-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + // run directly, not through a request (which runs frames): the search of an empty folder is over at once, but its + // result is applied between frames, and the row is there until then + let r = crate::menus::run_ui_command(&mut h.app, "file.findMissing", &json!({"folder": dir.to_string_lossy()})).unwrap().unwrap(); + assert_eq!(r["background"], true, "{r}"); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("findMissing", "Find Missing Photos")); + assert_eq!((rows[0].total, rows[0].cancellable), (0, false), "indeterminate, no ✕"); + assert!(h.step_until(T, |h| h.app.tasks.is_empty())); + assert!(h.app.session.activity.list().is_empty(), "the row goes with the task"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn auto_import_listing_has_no_row() { + let mut h = demo(); + let dir = std::env::temp_dir().join(format!("lc-activity-autoimport-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + h.app.session.import_defaults.auto_folder = Some(dir.to_string_lossy().to_string()); + // the watched folder is listed every few seconds, quietly + assert!(h.step_until(Duration::from_secs(10), |h| h.app.tasks.is_running("Auto Import"))); + assert!(h.app.session.activity.list().is_empty(), "{:?}", h.app.session.activity.list()); + h.app.session.import_defaults.auto_folder = None; + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn rows_added_later_grow_the_stack() { + let mut h = demo(); + let a = h.app.session.activity.start("export", "Exporting", Cancel::Yes); + a.progress(1, 24); + a.detail("LC01347.jpg"); + wait_visible(&mut h); + // two more tasks while the stack already shows one row: it grows to show them, nothing is cut off + let b = h.app.session.activity.start("previews", "Building previews", Cancel::Yes); + let c = h.app.session.activity.start("lightroom", "Reading Lightroom catalog", Cancel::Yes); + wait_visible(&mut h); + for _ in 0..5 { + h.step(); + } + let stack = h.view.ctx.memory(|m| m.area_rect(egui::Id::new("activity-stack"))).expect("the stack is drawn"); + for g in [&a, &b, &c] { + let r = rect(&h, &format!("activity:row:{}", g.id())); + assert!(stack.contains_rect(r), "row {} at {r:?} is cut off by the stack at {stack:?}", g.id()); + } +} diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index b98960495..894e1e4ec 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) { diff --git a/crates/ui-egui/src/tests_quit_unsaved.rs b/crates/ui-egui/src/tests_quit_unsaved.rs index a3da72be4..ef6fa8da4 100644 --- a/crates/ui-egui/src/tests_quit_unsaved.rs +++ b/crates/ui-egui/src/tests_quit_unsaved.rs @@ -79,7 +79,7 @@ fn quitting_with_unsaved_changes_asks_first() { assert_eq!(r["ok"], false, "{r}"); assert!(!crate::panels::notices::may_close(&mut h.app), "retried, still failing: ask"); h.step(); - let prompt = h.app.quit_prompt.clone().unwrap(); + let prompt = h.app.quit_prompt.clone().unwrap().text().to_string(); assert!(prompt.contains("1 change couldn't be written to disk") && prompt.contains("drive unplugged"), "{prompt}"); for b in ["button:quitRetry", "button:quitAnyway", "button:quitCancel"] { assert!(has(&h, b), "{b}"); diff --git a/crates/ui-egui/src/tests_sync.rs b/crates/ui-egui/src/tests_sync.rs index 0fa430891..b25ef6070 100644 --- a/crates/ui-egui/src/tests_sync.rs +++ b/crates/ui-egui/src/tests_sync.rs @@ -9,7 +9,7 @@ //! * Ticking "Remove missing photos" also moves those to Recently Deleted. //! * Cancel leaves the library as it was. //! * Synchronize hands the work to the background at once: the dialog closes, the app keeps -//! answering, and the photos arrive over the next frames under a progress window, as one undo +//! answering, and the photos arrive over the next frames under a row in the activity stack, as one undo //! step. use std::time::Duration; @@ -218,7 +218,7 @@ fn a_stopped_synchronize_says_it_was_stopped_and_stays_at_most_one_step() { let steps = h.app.session.undo.len(); let dlg = h.app.ui.dialog.take().expect("the dialog is open"); assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); - // the progress window's Cancel, before any frame committed anything + // Cancel (what the row's ✕ does), before any frame committed anything h.app.sync_run.as_mut().expect("running").cancel(); assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); @@ -254,3 +254,51 @@ fn a_synchronize_and_an_import_never_run_at_once() { let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &d); assert!(r.is_err() && h.app.sync_run.is_none(), "no synchronize while importing: {r:?}"); } + +#[test] +fn a_synchronize_shows_a_row_and_its_cross_stops_it() { + let dir = Scratch::new("row"); + let mut h = changed_folder(&dir); + open_and_scan(&mut h, &dir.path("trip")); + let dlg = h.app.ui.dialog.take().expect("the dialog is open"); + assert!(crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg).is_ok()); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("sync", "Synchronizing folder")); + assert!(rows[0].detail.ends_with("trip"), "the folder as the dialog named it: {:?}", rows[0].detail); + assert!(rows[0].cancellable); + // the row's ✕ (as `activity.cancel`): what was done stays, and the end says it was stopped + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.sync_run.is_none()), "it ends"); + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default(); + assert!(toast.contains("Stopped"), "{toast:?}"); + assert!(h.app.session.activity.list().is_empty(), "the row goes with the run"); +} + +#[test] +fn the_folders_scan_shows_a_row_and_its_cross_closes_the_dialog() { + let dir = Scratch::new("scanrow"); + let mut h = changed_folder(&dir); + // a scan that can't finish until the test lets it: reading the new file waits on a flag + let gate = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + h.app.session.media.file_probe = Some(std::sync::Arc::new(move |_: &str| { + while !g.load(std::sync::atomic::Ordering::Relaxed) { + std::thread::sleep(Duration::from_millis(5)); + } + Ok(lightcraft_engine::media::ProbeInfo { format: "PNG".into(), ..Default::default() }) + })); + crate::sync::open(&mut h.app, &dir.path("trip"), "trip", false).unwrap(); + let rows = h.app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].detail.as_str()), ("sync", "Looking for changes", "trip")); + assert!(rows[0].cancellable); + h.app.session.activity.cancel(rows[0].id).unwrap(); + h.step(); + assert!(h.app.sync.is_none(), "✕ stops the scan"); + assert!(h.app.ui.dialog.is_none(), "and closes its dialog: there is nothing to show"); + gate.store(true, std::sync::atomic::Ordering::Relaxed); + assert!(h.app.session.activity.list().is_empty()); + h.settle(SETTLE); + assert!(h.app.ui.dialog.is_none(), "a stopped scan opens nothing later"); +} diff --git a/docs/background-tasks.md b/docs/background-tasks.md new file mode 100644 index 000000000..576b3868f --- /dev/null +++ b/docs/background-tasks.md @@ -0,0 +1,65 @@ +# Long-running tasks and the activity stack + +Everything that takes a while runs off the UI thread and shows up in **one** place: the activity stack in the +top-left corner under the top bar (issue #345). Each task has a row with its name, a progress bar (a sweep while +the amount of work isn't known), the count and what it's working on, and ✕ if it can stop. Quitting asks first while +a task that can stop is running. Agents see the same list through `activity.list` / `activity.cancel`, and +`ui.inspect` reports it as `activity`. + +The registry is `lightcraft_engine::activity` (`crates/engine/src/activity.rs`, `Session::activity`). The stack is +`crates/ui-egui/src/panels/activity.rs`. Every job that can last more than a moment uses it; **don't add a progress +window, panel or progress toast of your own**. A dialog may still show progress inline (the Synchronize Folder +dialog counts files while it scans), and a toast at the end saying what happened is fine. + +## Adding a task + +```rust +use lightcraft_engine::activity::{Cancel, Unit}; + +// when the job starts, with the cancel flag the worker already checks between files +let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone())); +guard.progress(0, total as u64); + +// every frame (or from the worker, through `guard.handle()`) +guard.progress(done as u64, total as u64); +guard.detail(&file_name); +``` + +- **Keep the guard for exactly as long as the job runs.** Put it in the struct the UI polls (`ExportTask`, `ScanTask`, + `SyncRun`…), or move it into the worker thread. Dropping it removes the row, also when the worker panics, so a + dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, + `Clone + Send`). +- **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, + `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + translated, so add it to every catalog in `crates/ui-egui/locales/`. +- **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. + `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` + reads "3 of 25". +- **Detail:** what the job is on (a file name, a model's name). Detail set by engine code is a name, never a sentence. + UI code may set translated text. +- **Cancel:** + - `Cancel::Flag(Arc)` adopts the flag the job already checks. The row's ✕, `activity.cancel` and the + job's own Cancel then all set the same atomic. + - `Cancel::Yes`: the job polls `guard.is_cancelled()` itself. + - `Cancel::No`: the job can't stop; it gets no ✕ and doesn't make Quit ask. + - `set_cancellable(false)` for a phase that has to finish once started (a Lightroom import adding its photos). +- **Quiet periodic work** shows no row: the auto-import folder listing runs every few seconds through + `tasks::spawn(.., None, ..)`. + +## Examples in the code + +| Job | Where | Shows how to | +|---|---|---| +| Export, contact sheet | `crates/ui-egui/src/export_task.rs` | adopt the job's flag; update the row each frame | +| Build Previews | `crates/engine/src/cmd/previews.rs` | move the guard into the worker thread | +| Model downloads | `crates/engine/src/face_download.rs` | report progress through a `TaskHandle` in shared state | +| Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | +| Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | +| Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | + +## Testing + +Assert the row (`session.activity.list()`: kind, label, total, `cancellable`), that `activity.cancel(id)` stops the +job, and that the row is gone once the job ends. `crates/ui-egui/src/tests_activity.rs` has the helpers, and the +job-specific tests show the pattern (`tests_sync.rs`, `export_task.rs`, `tests_denoise.rs`…). A row only appears +on screen once its task is 0.5 s old, so tests that click its ✕ wait for `activity:cancel:` first. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index f6af190e0..552f76952 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: tasks that would be cut short are running — `button:quitAnyway` cancels them and quits, `button:quitCancel` keeps working — or unsaved changes: `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points | @@ -46,6 +46,8 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically | | `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}`; with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | +| `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | | `app.quit` | — | Close the app | diff --git a/docs/faces.md b/docs/faces.md index 3b1692c6a..996c82ca5 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -248,7 +248,7 @@ Turning recognition on starts a scan of the whole library, once, in the backgrou the running ones, so a worker that finishes has its next photo at once. - **Earlier scan speed (a different runner).** A mixed raw and JPEG library of 184 photos (the raws read through their embedded previews, 73 photos searched for faces) took about 29 seconds on that machine at full pace: roughly 6 photos a second, so the first scan of 10,000 photos - is a matter of half an hour; after that only new photos are looked at. Settings ▸ Faces shows how many are left, with a progress bar. After you accept a model's terms + is a matter of half an hour; after that only new photos are looked at. Settings ▸ Faces shows how many are left, with a progress bar, and the activity stack shows "Finding faces" meanwhile. After you accept a model's terms you are returned to that tab, so the download and then the scan can be watched there; the main window has no status bar. - Opening another library starts a fresh scan state; nothing learned about one library is used in another. diff --git a/docs/merge.md b/docs/merge.md index 600eb840f..e78c356fb 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -9,7 +9,7 @@ All algorithms are our own implementations from published papers (cited in the m - **App:** select the photos, then *Photo Merge ▸ HDR…* (`Ctrl+H`), *Panorama…* (`Ctrl+M`) or *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge - in the background (progress in a toast). The result is written next to the first photo as + in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as `-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` diff --git a/docs/parity.md b/docs/parity.md index 78d4b0fc7..fbc62f205 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -135,7 +135,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread with a progress window and Cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, with a progress window and Cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | +| LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | | LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | @@ -518,7 +518,7 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | | | LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe | | LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | | -| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `crates/preview/src/lib.rs` (`JobPool`), `crates/ui-egui/src/export_task.rs`, `crates/ui-egui/src/import.rs` | renders off the UI thread; exports started from the UI run on a worker thread with a progress panel and Cancel (`ui.inspect` → `export`), several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); imports show a progress window. No combined activity centre | +| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | | LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); no tiling beyond the buffer limit (CPU) | @@ -748,7 +748,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages, in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`) | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames under a progress window with Cancel, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/`), NFS (`/Network/Servers/`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); creating a folder and moving photos between folders are not here; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | @@ -764,7 +764,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | | LRC-LIB-CONVERT | Convert to DNG | P2 | ✅ | `cmd:photo.convertToDng`, `crates/engine/src/cmd/convert.rs` | Photo ▸ Convert to DNG: lossless DNG next to the raw with the settings embedded, photo (and its virtual copies) relinked; originals kept; undoable; the DNG is decoded and compared with the raw data, written to a temp file, synced, read back and only then given a free name (never replacing a file) — a failed write leaves no DNG and the photo on its raw (issue #106) | -| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (progress toasts, stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | +| LRC-LIB-PREVIEWS | Build / discard previews | P1 | ✅ | `cmd:library.buildPreviews`, `cmd:library.previewProgress`, `cmd:library.cancelPreviews`, `cmd:library.clearPreviews`, `crates/engine/src/cmd/previews.rs`, `crates/preview/src/lib.rs` | File ▸ Previews: build standard-sized (Settings → Performance size) or 1:1 previews of the selected / visible photos in the background (a row in the activity stack, ✕ to stop), discard the cache; disk thumbnail + view cache; no smart previews (offline proxies) | | LRC-LIB-SLIDESHOW-IMPROMPTU | Impromptu slideshow | P2 | ✅ | `cmd:view.slideshow` | see LR-VIEW-SLIDESHOW | | LRC-DEV-SNAPSHOTS | Named snapshots | P1 | ✅ | `cmd:version.create`, `cmd:version.restore` | = versions | | LRC-DEV-HISTORY | Full history panel | P1 | ✅ | `cmd:history.list`, `cmd:history.restore`, `cmd:history.clear` | row menu: copy step to before, create version from step, clear history | diff --git a/docs/whats-new.md b/docs/whats-new.md index 8ae60a7e4..11f01b07e 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,6 +2,15 @@ ## October 2026 +### Activity stack +- Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and + folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan, + AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working + on; ✕ stops the tasks that can stop. Three rows show, then "+N more". The separate export panel and the progress + windows and toasts are gone. +- Quitting while a task that can be stopped is running asks first ("Quit Anyway" stops it). +- Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`. + ### Smart album rules - The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted @@ -92,7 +101,7 @@ file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing - runs in the background with a progress window and Cancel; everything it does is one undo step, and no file on disk + runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk is touched. Agents use `folder.scanChanges` and `folder.synchronize`. ### Folder colour labels