From af9b1063cab65d57195e4f251fc6424ea47b0ae8 Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:10:36 +0200 Subject: [PATCH] Issue #345: activity stack in the top-left corner Every task in the engine's activity registry floats as a row under the top bar once it is half a second old: its name, a progress bar (a sweep while the amount of work isn't known), the count and what it is working on, and a cross for the ones that can be cancelled. Three rows, then "+N more". ui.inspect reports the same list as activity. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 6 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 6 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 8 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 6 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 6 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 6 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 6 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 6 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 6 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 6 + crates/ui-egui/src/panels/activity.rs | 155 ++++++++++++++++++++ crates/ui-egui/src/panels/mod.rs | 1 + crates/ui-egui/src/tests_activity.rs | 132 +++++++++++++++++ docs/control-protocol.md | 2 +- 24 files changed, 361 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/src/panels/activity.rs create mode 100644 crates/ui-egui/src/tests_activity.rs diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index ea00ae3c..c426d7bf 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -221,5 +221,9 @@ "; {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" } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index c4bded93..d4080884 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2016,5 +2016,6 @@ "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" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index ce6ace8c..4a829d82 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -221,5 +221,9 @@ "; {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" } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index eb13321a..27c5baa7 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1861,5 +1861,6 @@ "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" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 6e609329..93b36005 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -221,5 +221,9 @@ "; {n} gained synonyms": " ; avec de nouveaux synonymes : {n}", "Exported {n} keyword{}": "Mots-clés exportés : {n}{:.0}", " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One n’importe pas une liste dont un mot-clé contient ; , < ou > : {names}", - " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}" -} \ No newline at end of file + " — left out, as a keyword list can't hold them: {names}": " — omis, car une liste de mots-clés ne peut pas les contenir : {names}", + "{done} of {total}": "{done} sur {total}", + "{done} of {total} MB": "{done} sur {total} Mo", + "{pct} %": "{pct} %", + "+{n} more": "+{n} autres" +} diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index a978e983..4f2df670 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1861,5 +1861,6 @@ "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" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index e4976420..7143491e 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -221,5 +221,9 @@ "; {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}件" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 4d9c8ae7..35b7954a 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1863,5 +1863,6 @@ "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": "表示を減らす" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index b6c1c23b..240a84b8 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -221,5 +221,9 @@ "; {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" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 318120d8..617b538b 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1861,5 +1861,6 @@ "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" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index cd55e708..19e3d38f 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -221,5 +221,9 @@ "; {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}" } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index b51355c7..e16251d7 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1574,5 +1574,6 @@ "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": "Свернуть" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 2196c6ba..6cd982f1 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -221,5 +221,9 @@ "; {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} ще" } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 52a30fb7..5ed8bbc8 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2055,5 +2055,6 @@ "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": "Показати менше" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index e006cdfd..ecbea7d9 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -221,5 +221,9 @@ "; {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} 项" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index d654240b..95a5a728 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1861,5 +1861,6 @@ "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": "收起" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index c47f4569..29aa9518 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -221,5 +221,9 @@ "; {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} 項" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index ef8160a5..57834fb7 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1861,5 +1861,6 @@ "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": "收合" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 1209a6c7..deff9007 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -114,6 +114,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "export": {"running": app.export.as_ref().map(crate::export_task::ExportTask::status), "last": app.last_export_result}, "import": app.import.as_ref().map(crate::import::ImportTask::status), "tasks": app.tasks.labels(), + // every long-running task in flight, as the activity stack shows them (`activity.list`) + "activity": app.session.activity.list(), // commands waiting on a native file dialog shown off the UI thread (`pick`) "fileDialogs": app.pending_picks.iter().map(|p| p.command.clone()).collect::>(), "memory": memory(app), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 50587c86..64d59bc9 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod theme; pub mod titlebar; pub mod widgets; +#[cfg(test)] +mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] @@ -302,6 +304,8 @@ pub struct LightcraftApp { /// The library failed to open at launch: the blocking window, then the temporary-session /// banner (issue #100). Cleared once a library opens. pub library_problem: Option, + /// The activity stack shows every task, not just the first few ("+N more" was clicked). + pub activity_expanded: bool, } impl LightcraftApp { @@ -367,6 +371,7 @@ impl LightcraftApp { display_applied: None, display_error: None, library_problem: None, + activity_expanded: false, model_setup: Default::default(), } } @@ -1054,6 +1059,7 @@ impl LightcraftApp { import::scan_progress(self, &ctx); lightroom_import::progress(self, &ctx); export_task::poll(self, &ctx); + panels::activity::show(self, &ctx); pick::poll(self, &ctx); panels::grid::drag_feedback(self, &ctx); panels::left::album_drag_feedback(self, &ctx); diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs new file mode 100644 index 00000000..5a2e6095 --- /dev/null +++ b/crates/ui-egui/src/panels/activity.rs @@ -0,0 +1,155 @@ +//! 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| { + 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 { + // the task may have just finished: nothing to tell the user then + let _ = app.run("activity.cancel", json!({"id": id})); + } + if toggle { + app.activity_expanded = !app.activity_expanded; + } +} + +/// 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| { + ui.set_max_width(label_w); + ui.add(egui::Label::new(RichText::new(tr(&task.label)).font(t.font(12.5)).color(t.text)).truncate()); + }); + if cross { + let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); + register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); + clicked = resp.on_hover_text(tr("Cancel")).clicked(); + } + }); + bar(ui, t, task, width); + let line = detail_line(task); + if !line.is_empty() { + ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate()); + } + }); + register(ui.ctx(), format!("activity:row:{}", task.id), r.response.rect); + clicked +} + +/// The progress bar; while the amount of work isn't known, a segment sweeps across. +fn bar(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) { + let (rect, _) = ui.allocate_exact_size(vec2(width, 5.0), Sense::hover()); + let p = ui.painter(); + p.rect_filled(rect, 2.5, t.field); + let fill = if task.total > 0 { + let frac = (task.done as f64 / task.total as f64).clamp(0.0, 1.0) as f32; + Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height())) + } else { + let phase = (ui.input(|i| i.time) * 0.7).fract() as f32; + let w = rect.width() * 0.3; + let x = rect.left() + (rect.width() + w) * phase - w; + Rect::from_min_max(pos2(x.max(rect.left()), rect.top()), pos2((x + w).min(rect.right()), rect.bottom())) + }; + if fill.width() > 0.0 { + p.rect_filled(fill, 2.5, if task.cancelling { t.text_dim } else { t.accent }); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index d87d6789..a8d99aed 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Window regions and panels. +pub mod activity; pub mod bottombar; pub mod chips; pub mod compare; diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs new file mode 100644 index 00000000..23020ddb --- /dev/null +++ b/crates/ui-egui/src/tests_activity.rs @@ -0,0 +1,132 @@ +//! 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); + h.step(); + 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(); + 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); + 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); +} + +#[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:?}"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 1de4a46c..7ce11557 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ mode ([mcp.md](mcp.md)) is a thin layer over this channel. Implementation: | `engine.commands` | — | Engine + UI commands: id, label, menu, shortcut, params doc, enabled | | `ui.menu.list` | — | Menu entries (flat: id, label, menu path, shortcut, enabled) | | `ui.menu.tree` | — | The menu bar as shown (File … Help): items `{id, params?, label, shortcut?, enabled, checked?}`, separators, submenus — the model behind the native macOS menu bar and the in-window menus | -| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | +| `ui.inspect` | — | UI state, window, `loupe` (`source`, and `region: {full, window, pending}` when a zoomed view also renders the window on screen at no more than 100 % — `full` is the frame it is cut from, `window` its `[x, y, w, h]` — and `regionBefore` for the Before side of a Before/After view), canvas/image rects, `scroll: {grid, filmstrip}` (scroll offsets in points, `null` until drawn), active photo, selection, perf (`frameMs` = layout, `logicMs` = per-frame logic before it, `updateMs` = both, `maxUpdateMs`, `fps`, render queue …, `gpu` = adapter in use, `gpuReason` = why renders don't use the GPU, `gpuFallback` = latest render redone on the CPU and why — see `docs/gpu-pipeline.md`), status, memory (bytes per cache, see `library.memory`; plus stage caches — `budgetBytes`, `trimmed`, `sharedSourceBytes` (the one device copy of a big original) — and textures), `export: {running: {total, done, current} \| null, last}`, `notices` (warnings waiting to be shown, e.g. a damaged settings file; OK = `button:noticeOk`), `quitPrompt` (why quitting was stopped: unsaved changes; `button:quitRetry` / `button:quitAnyway` / `button:quitCancel`), `import: {done, total, imported, cancelled} \| null` (an import runs on a worker thread), `tasks` (other background work: `Find Missing Photos`, `Auto Import`), `activity` (every long-running task in flight as the activity stack in the top-left corner shows it, same shape as `activity.list`; a row appears once its task is 0.5 s old, ids `activity:row:`, ✕ = `activity:cancel:`, `activity:more` expands past 3 rows), `fileDialogs` (commands waiting on a native file dialog, which runs off the UI thread; they run again with the answer when it closes), `copied` (the text the UI last put on the clipboard, `null` until something is copied) | | `ui.widgets` | `{filter?}` | On-screen widgets `{id, rect: [x, y, w, h]}` (screen points) | | `ui.clickWidget` / `ui.dragWidget` / `ui.hoverWidget` | `{id, count?, button?, fx?, fy?}` (`button: "right"` right-clicks) / `{id, toX?, toY?, dx?, dy?, steps?}` / `{id, fx?, fy?}` | Real egui input on a widget (hover: the pointer rests on it, e.g. for preset/profile previews) | | `ui.move` / `ui.click` / `ui.drag` | `{x, y, count?, button?}` / `{x, y, toX, toY, steps?}` | Raw pointer input, screen points |