This commit is contained in:
Brandon Thomas
2026-10-10 03:45:41 -04:00
66 changed files with 2142 additions and 290 deletions
+4
View File
@@ -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.
Generated
+1
View File
@@ -3352,6 +3352,7 @@ dependencies = [
"lightcraft-engine",
"lightcraft-faces",
"lightcraft-geom",
"lightcraft-merge",
"lightcraft-meta",
"lightcraft-pipeline",
"lightcraft-preview",
+391
View File
@@ -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<Inner>,
}
#[derive(Default)]
struct Inner {
/// Oldest first.
tasks: Mutex<Vec<Arc<Entry>>>,
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<AtomicBool>),
}
/// 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<String>,
cancel: Option<Arc<AtomicBool>>,
cancellable: AtomicBool,
}
fn lock<T>(m: &Mutex<T>) -> 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<TaskInfo> {
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<TaskInfo> {
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<Entry>,
}
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<Arc<AtomicBool>> {
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<Entry>,
}
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<_>>(), 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));
}
}
+65
View File
@@ -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<CommandSpec> {
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());
}
}
+2 -1
View File
@@ -368,7 +368,7 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
/// `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<Value> {
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(),
+7 -1
View File
@@ -356,7 +356,13 @@ fn download(s: &mut Session, p: &Value) -> Result<Value> {
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}))
}
+22 -1
View File
@@ -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,
+1 -1
View File
@@ -464,7 +464,7 @@ pub fn specs() -> Vec<CommandSpec> {
),
));
}
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}))
}
),
+2
View File
@@ -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
})
}
+33 -14
View File
@@ -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<AtomicBool>,
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<usize
v
}
fn run_all(jobs: Vec<Vec<RenderJob>>, 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<Vec<RenderJob>>, 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<Vec<RenderJob>>, 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<Value> {
let jobs: Vec<Vec<RenderJob>> = 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<SmartJob>,
state: Option<&PreviewBuild>,
state: Option<(&PreviewBuild, &TaskGuard)>,
) -> std::result::Result<SmartCounts, String> {
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<Value> {
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);
}
+28
View File
@@ -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<crate::activity::TaskGuard>,
}
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 {
+76 -4
View File
@@ -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<State>,
cancel: AtomicBool,
cancel: Arc<AtomicBool>,
/// The download's row in the activity stack.
task: Option<TaskHandle>,
}
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 `<models dir>/.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<u8>) -> 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<u8> = (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");
+4
View File
@@ -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<u64>,
/// The scan's row in the activity stack, while photos are left.
pub task: Option<crate::activity::TaskGuard>,
/// The most photos left at once since the scan last finished (the row's whole).
pub peak: u64,
}
impl FacesState {
+2 -1
View File
@@ -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<std::sync::atomic::AtomicBool>,
}
/// The result of [`scan_with`]: the candidates, and the probes to keep for the import that follows.
+5
View File
@@ -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<sync::FolderChanges>,
/// 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<std::sync::Arc<cmd::previews::PreviewBuild>>,
/// 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,
+77 -5
View File
@@ -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<State>,
cancel: AtomicBool,
cancel: Arc<AtomicBool>,
/// The download's row in the activity stack.
task: Option<TaskHandle>,
}
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 `<models dir>/.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<u8>) -> 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<u8> = (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");
+19 -4
View File
@@ -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<AtomicBool>,
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<String>, dir: PathBuf, opts: Options) -> Result<bool, String> {
/// 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<String>,
dir: PathBuf,
opts: Options,
activity: &Activity,
name: &str,
) -> Result<bool, String> {
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);
+3 -3
View File
@@ -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<bool, String> {
pub fn start_download(&self, activity: &crate::activity::Activity) -> Result<bool, String> {
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)
}
}
+36
View File
@@ -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<u64> = 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<u64> = 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]
+32
View File
@@ -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::<Vec<_>>();
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);
}
+36
View File
@@ -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::<Vec<_>>();
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::<Vec<_>>();
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);
}
+6
View File
@@ -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);
}
+48
View File
@@ -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);
}
+1
View File
@@ -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 }
+8 -1
View File
@@ -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"
}
+16 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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"
}
+16 -1
View File
@@ -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"
}
+9 -2
View File
@@ -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}"
}
" — 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"
}
+16 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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}合成をキャンセルしました"
}
+16 -1
View File
@@ -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": "変更を確認中"
}
+8 -1
View File
@@ -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"
}
+16 -1
View File
@@ -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"
}
+8 -1
View File
@@ -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}: слияние отменено"
}
+17 -1
View File
@@ -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": "Поиск изменений"
}
+8 -1
View File
@@ -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} скасовано"
}
+16 -1
View File
@@ -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": "Пошук змін"
}
+8 -1
View File
@@ -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} 合并"
}
+16 -1
View File
@@ -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": "正在查找更改"
}
+8 -1
View File
@@ -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}合併"
}
+16 -1
View File
@@ -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": "正在尋找變更"
}
+3 -1
View File
@@ -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::<Vec<_>>(),
"memory": memory(app),
+36 -33
View File
@@ -1,8 +1,8 @@
//! Background export: the Export dialog, File → Export with Preset and Export with Previous hand
//! their batch to a worker thread so the window stays responsive. Photos are prepared on the UI
//! thread ([`lightcraft_engine::export::prepare_export`]: cheap, needs the session) and rendered,
//! encoded and written on the worker (several side by side: [`run_batch`]); a progress panel shows
//! the count and a Cancel button.
//! encoded and written on the worker (several side by side: [`run_batch`]); its row in the
//! activity stack shows the count, the file in progress and ✕ (issue #345).
//!
//! Needs [`crate::Services::write_shared`] (a thread-safe writer); without it (web) the batch runs
//! synchronously as before.
@@ -11,12 +11,11 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex};
use egui::Align2;
use lightcraft_engine::activity::{Cancel, TaskGuard};
use lightcraft_engine::export::{Destination, ExportOptions, PreparedExport, run_batch};
use serde_json::{Value, json};
use crate::LightcraftApp;
use crate::theme::Tokens;
pub struct ExportTask {
pub total: usize,
@@ -24,6 +23,8 @@ pub struct ExportTask {
pub progress: Arc<Mutex<(usize, String)>>,
pub cancel: Arc<AtomicBool>,
rx: Receiver<Result<Vec<Value>, String>>,
/// The export's row in the activity stack (`activity.cancel` sets `cancel`).
guard: TaskGuard,
}
impl ExportTask {
@@ -59,11 +60,13 @@ pub fn start(app: &mut LightcraftApp, items: Vec<PreparedExport>, opts: ExportOp
std::thread::Builder::new().name("export".into()).spawn(work).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
work();
app.export = Some(ExportTask { total, progress, cancel, rx });
let guard = app.session.activity.start("export", "Exporting", Cancel::Flag(cancel.clone()));
guard.progress(0, total as u64);
app.export = Some(ExportTask { total, progress, cancel, rx, guard });
Ok(json!({"background": true, "total": total}))
}
/// Export a single paginated PDF using the same progress and cancellation UI as image exports.
/// Export a single paginated PDF with the same activity row and cancellation as image exports.
pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> {
if app.export.is_some() {
return Err("an export is already running".into());
@@ -103,11 +106,13 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va
std::thread::Builder::new().name("contact-sheet".into()).spawn(work).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
work();
app.export = Some(ExportTask { total, progress, cancel, rx });
let row = app.session.activity.start("export", "Exporting contact sheet", Cancel::Flag(cancel.clone()));
row.progress(0, total as u64);
app.export = Some(ExportTask { total, progress, cancel, rx, guard: row });
Ok(json!({"background": true, "total": total}))
}
/// Per frame: draw the progress panel; when the batch finishes, report it.
/// Per frame: keep the activity row up to date; when the batch finishes, report it.
pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
let Some(task) = &app.export else { return };
match task.rx.try_recv() {
@@ -144,30 +149,8 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
}
Err(std::sync::mpsc::TryRecvError::Empty) => {
let (done, current) = task.progress.lock().map(|g| g.clone()).unwrap_or_default();
let (total, cancel) = (task.total, task.cancel.clone());
let t = Tokens::get(ctx);
egui::Window::new(crate::i18n::tr("Exporting"))
.title_bar(false)
.resizable(false)
.anchor(Align2::LEFT_BOTTOM, [16.0, -56.0])
.fixed_size([300.0, 64.0])
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(crate::i18n::tr_format!("Exporting {} of {total}", (done + 1).min(total), total = total))
.color(t.text),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let stopping = cancel.load(Ordering::Relaxed);
if crate::widgets::text_button(ui, "exportCancel", if stopping { "Stopping…" } else { "Cancel" }, false).clicked() {
cancel.store(true, Ordering::Relaxed);
}
});
});
ui.add(egui::ProgressBar::new(done as f32 / total.max(1) as f32).desired_width(280.0));
ui.label(egui::RichText::new(current).size(11.0).color(t.text_dim));
});
ctx.request_repaint_after(std::time::Duration::from_millis(100));
task.guard.progress(done as u64, task.total as u64);
task.guard.detail(&current);
}
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(
+12 -3
View File
@@ -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");
+40 -70
View File
@@ -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<lightcraft_engine::Selection>,
run: Option<ImportRun>,
/// The import's row in the activity stack (its ✕ and `activity.cancel` set the run's cancel flag).
guard: Option<lightcraft_engine::activity::TaskGuard>,
}
impl ImportTask {
@@ -391,6 +394,14 @@ pub struct ScanTask {
browse: bool,
/// What is being scanned (the review's source).
sources: Vec<String>,
/// 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<String>) -> Result<Value, String
std::thread::spawn(job);
#[cfg(target_arch = "wasm32")]
job();
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources });
let guard = scan_guard(app, &progress, false);
app.scan = Some(ScanTask { progress, rx, copy: false, browse: false, sources, guard });
Ok(json!({"scanning": true}))
}
@@ -477,7 +489,8 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) ->
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());
+30 -16
View File
@@ -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<String>,
/// Quitting was stopped because changes couldn't be saved: the prompt's text.
pub quit_prompt: Option<String>,
/// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running.
pub quit_prompt: Option<QuitPrompt>,
/// Quit Anyway was chosen: the window may close with unsaved changes.
pub quit_confirmed: bool,
control_rx: Option<Receiver<ControlRequest>>,
@@ -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<panels::library_problem::LibraryProblem>,
/// 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);
+108 -48
View File
@@ -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<Message>,
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<AtomicBool>,
total: Arc<AtomicUsize>,
done: Arc<AtomicUsize>,
rx: mpsc::Receiver<Message>,
) -> 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<Message>, Arc<AtomicBool>, Arc<AtomicUsize>, Arc<AtomicUsize>) {
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 {
+1 -1
View File
@@ -1212,7 +1212,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re
}
};
app.ui.last_find_missing = None;
if let Err(e) = crate::tasks::spawn(app, LABEL, work, done) {
if let Err(e) = crate::tasks::spawn(app, LABEL, Some("findMissing"), work, done) {
return Some(Err(e));
}
if p.get("wait").and_then(Value::as_bool).unwrap_or(false) {
+16 -5
View File
@@ -4,7 +4,7 @@
//! Previews and the final merge run [`lightcraft_engine::merge::MergeJob`]s on worker threads with
//! progress and cancellation; the window stays responsive. Changing an option cancels the running
//! preview and starts a new one. The final merge keeps running after the dialog closes (progress
//! in a toast); when it finishes, the result is written, imported and selected
//! in the activity stack); when it finishes, the result is written, imported and selected
//! ([`lightcraft_engine::Session::finish_merge`]).
use std::sync::atomic::{AtomicBool, Ordering};
@@ -12,6 +12,7 @@ use std::sync::mpsc::{Receiver, channel};
use std::sync::{Arc, Mutex};
use lightcraft_catalog::PhotoId;
use lightcraft_engine::activity::{Cancel, TaskGuard, Unit};
use lightcraft_engine::merge::{MergeJob, MergeOutput};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
@@ -88,6 +89,8 @@ pub struct MergeTask {
pub progress: Arc<Mutex<(f32, String)>>,
pub cancel: Arc<AtomicBool>,
rx: Receiver<Result<MergeOutput, String>>,
/// The final merge's row in the activity stack (previews have none).
guard: Option<TaskGuard>,
}
/// 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<Value,
t.cancel.store(true, Ordering::Relaxed);
}
let ids = app.merge.ids.clone();
let task = spawn(app, opts, &ids, false)?;
let mut task = spawn(app, opts, &ids, false)?;
let guard = app.session.activity.start("merge", "Merging", Cancel::Flag(task.cancel.clone()));
guard.set_unit(Unit::Percent);
guard.progress(0, 1000);
task.guard = Some(guard);
app.merge.final_task = Some(task);
app.merge.last.insert(opts.command.clone(), opts.clone());
Ok(json!({"started": true, "photos": ids.len()}))
@@ -265,6 +272,7 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) {
app.merge.last_result = Some(v);
app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what));
}
Err(_) if t.cancel.load(Ordering::Relaxed) => 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));
}
}
+165
View File
@@ -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 });
}
}
+3 -2
View File
@@ -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
+1
View File
@@ -1,5 +1,6 @@
//! Window regions and panels.
pub mod activity;
pub mod bottombar;
pub mod chips;
pub mod compare;
+36 -8
View File
@@ -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::<Vec<_>>().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),
+26 -32
View File
@@ -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<SyncProgress>,
rx: std::sync::mpsc::Receiver<FolderChanges>,
/// 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<Value, String> {
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<SyncCommit>,
/// 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();
}
}
+9 -3
View File
@@ -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<dyn FnOnce(&mut LightcraftApp, &egui::Context) + Send>;
struct Task {
label: String,
rx: Receiver<Finish>,
/// Its row in the activity stack (none for quiet tasks); goes with the task.
_guard: Option<lightcraft_engine::activity::TaskGuard>,
}
/// 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<T: Send + 'static>(
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<T: Send + 'static>(
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(())
}
+395
View File
@@ -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<u64> = h.app.session.visible_cloned().iter().take(n).map(|p| p.0).collect();
assert_eq!(ids.len(), n, "the demo library has {n} photos");
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": ids}}), T);
h.request("engine.execute", json!({"command": "dialog.export", "params": {}}), T);
h.step();
if let Some(crate::state::Dialog::Export { full_size, resize, dir, .. }) = &mut h.app.ui.dialog {
*full_size = false;
*resize = lightcraft_engine::export::Resize::long_edge(64);
*dir = "/lc-test-out".into();
}
let r = h.request("ui.dialog.confirm", json!({}), T);
assert_eq!(r["ok"], true, "{r}");
}
#[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<u64> = 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());
}
}
+7 -3
View File
@@ -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) {
+1 -1
View File
@@ -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}");
+50 -2
View File
@@ -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");
}
+65
View File
@@ -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<AtomicBool>)` 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:<id>` first.
+3 -1
View File
@@ -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:<id>`, ✕ = `activity:cancel:<id>`, `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 |
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
`<name>-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?}`
+4 -4
View File
File diff suppressed because one or more lines are too long
+10 -1
View File
@@ -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