mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
Merge PR #601
This commit is contained in:
@@ -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
@@ -3352,6 +3352,7 @@ dependencies = [
|
||||
"lightcraft-engine",
|
||||
"lightcraft-faces",
|
||||
"lightcraft-geom",
|
||||
"lightcraft-merge",
|
||||
"lightcraft-meta",
|
||||
"lightcraft-pipeline",
|
||||
"lightcraft-preview",
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}))
|
||||
}
|
||||
),
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}合成をキャンセルしました"
|
||||
}
|
||||
|
||||
@@ -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": "変更を確認中"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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}: слияние отменено"
|
||||
}
|
||||
|
||||
@@ -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": "Поиск изменений"
|
||||
}
|
||||
|
||||
@@ -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} скасовано"
|
||||
}
|
||||
|
||||
@@ -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": "Пошук змін"
|
||||
}
|
||||
|
||||
@@ -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} 合并"
|
||||
}
|
||||
|
||||
@@ -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": "正在查找更改"
|
||||
}
|
||||
|
||||
@@ -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}合併"
|
||||
}
|
||||
|
||||
@@ -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": "正在尋找變更"
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(¤t);
|
||||
}
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
|
||||
app.export = None;
|
||||
@@ -214,7 +197,7 @@ mod contact_sheet_tests {
|
||||
assert_eq!(h.app.pending_picks.len(), 1);
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
// The progress window lays out text, which egui only allows inside a frame.
|
||||
// The activity stack lays out text, which egui only allows inside a frame.
|
||||
h.step();
|
||||
if h.app.last_export_result.is_some() {
|
||||
break;
|
||||
@@ -232,6 +215,26 @@ mod contact_sheet_tests {
|
||||
assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contact_sheet_export_shows_a_row_and_its_cross_stops_it() {
|
||||
let mut app = LightcraftApp::new(
|
||||
lightcraft_engine::Session::with_demo(),
|
||||
crate::Services { write_shared: Some(Arc::new(|_, _| Ok(()))), ..Default::default() },
|
||||
);
|
||||
let ids: Vec<_> = app.session.catalog.photos().take(24).map(|p| p.id.0).collect();
|
||||
start_contact_sheet(&mut app, &json!({"path": "Sheet.pdf", "ids": ids})).unwrap();
|
||||
let rows = app.session.activity.list();
|
||||
assert_eq!(rows.len(), 1, "{rows:?}");
|
||||
assert_eq!((rows[0].kind, rows[0].label.as_str(), rows[0].total), ("export", "Exporting contact sheet", 24));
|
||||
assert!(rows[0].cancellable);
|
||||
app.session.activity.cancel(rows[0].id).unwrap();
|
||||
assert!(app.export.as_ref().unwrap().cancel.load(Ordering::Relaxed), "the stack's ✕ stops the sheet");
|
||||
let task = app.export.take().unwrap();
|
||||
assert!(task.rx.recv_timeout(Duration::from_secs(20)).unwrap().is_err(), "a stopped sheet is not written");
|
||||
drop(task);
|
||||
assert!(app.session.activity.list().is_empty(), "the row goes with the export");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_contact_sheet_never_calls_writer() {
|
||||
let mut app = LightcraftApp::new(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
//! Window regions and panels.
|
||||
|
||||
pub mod activity;
|
||||
pub mod bottombar;
|
||||
pub mod chips;
|
||||
pub mod compare;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
+10
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user