NC Imago · Docs

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 imago_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 imago_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);

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
Slideshow export crates/engine/src/cmd/slideshow.rs, crates/engine/src/slideshow_export.rs an engine command that owns its worker and row; progress from the worker through a TaskHandle; results read back by command
Model downloads crates/engine/src/face_download.rs report progress through a TaskHandle in shared state
LRCAT catalog import crates/ui-egui/src/lrcat_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(..))
Reload from Disk crates/ui-egui/src/reload.rs, crates/engine/src/cmd/reload.rs a command split into plan / probe / apply so the app reads the files on a worker thread (tasks::spawn_tracked: progress and Cancel::Flag); the result is committed through Session::execute_fn (undo step, save, XMP auto-write), and dropped for a photo a later-started reload has already updated; wait: true runs it in place
Reading focus points crates/engine/src/focus_scan.rs a worker paced by the UI's per-frame pump (focus.pump {pace}); Cancel::Flag; commits in batches without undo steps

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.