A raw's embedded JPEG, a thumbnail, the previous render after an edit or the magnified frame under a 1:1 window all look like the finished photo. Once a render has been on its way for 0.5 s with nothing new on screen, a pill at the bottom of the photo (widget `loupe:rendering`) says so; above the Navigator when that is in the way. The wait starts when the render starts coming (an idle app draws no frames), so a quick render never shows it, nor does a drag whose drafts keep up; once up it stays until nothing is coming, so slow drafts don't make it blink. It watches what is on screen: the After or the Before, and the windows in use. Not an activity row: it says the pixels aren't final (docs/background-tasks.md). Co-Authored-By: Claude <[email protected]>
4.5 KiB
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. A view saying its pixels
aren't final isn't progress either: the loupe's "Rendering…" pill (loupe:rendering) marks a stand-in or an
out-of-date render on the photo itself, and the render behind it holds no row (a slider drag replaces it many times a
second).
Adding a task
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 aTaskHandle(guard.handle(),Clone + Send). kindis a stable camelCase id that agents filter by (export,import,scan,sync,previews,download,faces…).labelis a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog incrates/ui-egui/locales/.- Progress:
progress(done, total). Atotalof 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::Percentreads "40 %". The defaultUnit::Countreads "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.canceland the job's own Cancel then all set the same atomic.Cancel::Yes: the job pollsguard.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.