From 98f65ff0d759dae691d35db3df612c66db46d42d Mon Sep 17 00:00:00 2001 From: Andrey Vyrodov Date: Fri, 9 Oct 2026 23:15:57 +0200 Subject: [PATCH] Issue #345: ask before quitting while tasks run Quitting while an import, an export or another task that can be cancelled is running asks first: Quit Anyway cancels them (then any unsaved changes still get their own question), Cancel keeps working. Tasks without a cross (the face scan, AI Denoise) pick up again on the next launch and don't ask. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/de-formats.json | 4 +- crates/ui-egui/locales/de.json | 3 +- crates/ui-egui/locales/es-formats.json | 4 +- crates/ui-egui/locales/es.json | 3 +- crates/ui-egui/locales/fr-formats.json | 4 +- crates/ui-egui/locales/fr.json | 3 +- crates/ui-egui/locales/ja-formats.json | 4 +- crates/ui-egui/locales/ja.json | 3 +- crates/ui-egui/locales/pt-br-formats.json | 4 +- crates/ui-egui/locales/pt-br.json | 3 +- crates/ui-egui/locales/ru-formats.json | 4 +- crates/ui-egui/locales/ru.json | 3 +- crates/ui-egui/locales/uk-formats.json | 4 +- crates/ui-egui/locales/uk.json | 3 +- crates/ui-egui/locales/zh-hans-formats.json | 4 +- crates/ui-egui/locales/zh-hans.json | 3 +- crates/ui-egui/locales/zh-hant-formats.json | 4 +- crates/ui-egui/locales/zh-hant.json | 3 +- crates/ui-egui/src/control.rs | 2 +- crates/ui-egui/src/lib.rs | 22 ++++++++++- crates/ui-egui/src/panels/notices.rs | 44 +++++++++++++++++---- crates/ui-egui/src/tests_activity.rs | 31 +++++++++++++++ crates/ui-egui/src/tests_quit_unsaved.rs | 2 +- docs/control-protocol.md | 2 +- 24 files changed, 135 insertions(+), 31 deletions(-) diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index c426d7bf..3597ffdc 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} von {total}", "{done} of {total} MB": "{done} von {total} MB", "{pct} %": "{pct} %", - "+{n} more": "+{n} weitere" + "+{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." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index d4080884..55fb5246 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -2017,5 +2017,6 @@ "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", - "Show less": "Weniger anzeigen" + "Show less": "Weniger anzeigen", + "Quit while tasks are running?": "Beenden, während Aufgaben laufen?" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 4a829d82..ea502198 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} de {total}", "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct} %", - "+{n} more": "+{n} más" + "+{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." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index 27c5baa7..5623785f 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -1862,5 +1862,6 @@ "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", - "Show less": "Mostrar menos" + "Show less": "Mostrar menos", + "Quit while tasks are running?": "¿Salir con tareas en curso?" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index 93b36005..f3907689 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} sur {total}", "{done} of {total} MB": "{done} sur {total} Mo", "{pct} %": "{pct} %", - "+{n} more": "+{n} autres" + "+{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." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index 4f2df670..7e772de2 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -1862,5 +1862,6 @@ "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", - "Show less": "Afficher moins" + "Show less": "Afficher moins", + "Quit while tasks are running?": "Quitter pendant que des tâches sont en cours ?" } diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 7143491e..f42118d5 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{total}件中{done}件", "{done} of {total} MB": "{done} / {total} MB", "{pct} %": "{pct}%", - "+{n} more": "ほか{n}件" + "+{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}。終了すると停止します。" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 35b7954a..3e0e2ff3 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -1864,5 +1864,6 @@ "Not saved": "保存されていません", "A folder is being synchronized": "フォルダを同期中です", "Wait for the import to finish": "読み込みが終わるまでお待ちください", - "Show less": "表示を減らす" + "Show less": "表示を減らす", + "Quit while tasks are running?": "タスクの実行中に終了しますか?" } diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index 240a84b8..8bd5bcae 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} de {total}", "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct}%", - "+{n} more": "+{n} mais" + "+{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." } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index 617b538b..0406984a 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -1862,5 +1862,6 @@ "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", - "Show less": "Mostrar menos" + "Show less": "Mostrar menos", + "Quit while tasks are running?": "Sair com tarefas em andamento?" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 19e3d38f..53a54f6c 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} из {total}", "{done} of {total} MB": "{done} из {total} МБ", "{pct} %": "{pct} %", - "+{n} more": "ещё {n}" + "+{n} more": "ещё {n}", + "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", + "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index e16251d7..6f512e40 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -1575,5 +1575,6 @@ "Not saved": "Не сохранено", "A folder is being synchronized": "Идёт синхронизация папки", "Wait for the import to finish": "Дождитесь окончания импорта", - "Show less": "Свернуть" + "Show less": "Свернуть", + "Quit while tasks are running?": "Выйти, пока выполняются задачи?" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 6cd982f1..9562c8f9 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done} з {total}", "{done} of {total} MB": "{done} з {total} МБ", "{pct} %": "{pct} %", - "+{n} more": "+{n} ще" + "+{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}. Вихід зупинить їх." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 5ed8bbc8..d46fbdcd 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -2056,5 +2056,6 @@ "Not saved": "Не збережено", "A folder is being synchronized": "Тека синхронізується", "Wait for the import to finish": "Дочекайтеся завершення імпорту", - "Show less": "Показати менше" + "Show less": "Показати менше", + "Quit while tasks are running?": "Вийти, поки виконуються завдання?" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index ecbea7d9..cf28c11a 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done}/{total}", "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", - "+{n} more": "还有 {n} 项" + "+{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}。退出将停止它们。" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index 95a5a728..59bb0bce 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -1862,5 +1862,6 @@ "Not saved": "未保存", "A folder is being synchronized": "正在同步文件夹", "Wait for the import to finish": "请等待导入完成", - "Show less": "收起" + "Show less": "收起", + "Quit while tasks are running?": "要在任务运行时退出吗?" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 29aa9518..61e7cef8 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -225,5 +225,7 @@ "{done} of {total}": "{done}/{total}", "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", - "+{n} more": "還有 {n} 項" + "+{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}。結束將停止它們。" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 57834fb7..9ba3d6cb 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -1862,5 +1862,6 @@ "Not saved": "未儲存", "A folder is being synchronized": "正在同步資料夾", "Wait for the import to finish": "請等待匯入完成", - "Show less": "收合" + "Show less": "收合", + "Quit while tasks are running?": "要在工作執行時結束嗎?" } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index deff9007..a8643db1 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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})), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 64d59bc9..bae21dac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -191,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 @@ -215,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, - /// Quitting was stopped because changes couldn't be saved: the prompt's text. - pub quit_prompt: Option, + /// Quitting was stopped to ask first: changes couldn't be saved, or tasks are running. + pub quit_prompt: Option, /// Quit Anyway was chosen: the window may close with unsaved changes. pub quit_confirmed: bool, control_rx: Option>, diff --git a/crates/ui-egui/src/panels/notices.rs b/crates/ui-egui/src/panels/notices.rs index 62be7fe3..619efe1d 100644 --- a/crates/ui-egui/src/panels/notices.rs +++ b/crates/ui-egui/src/panels/notices.rs @@ -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::>().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), diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 23020ddb..74a6ba59 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -130,3 +130,34 @@ fn long_detail_stays_inside_the_stack() { let row = rect(&h, &format!("activity:row:{}", g.id())); assert!(row.width() <= WIDTH + 1.0, "{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)); +} diff --git a/crates/ui-egui/src/tests_quit_unsaved.rs b/crates/ui-egui/src/tests_quit_unsaved.rs index a3da72be..ef6fa8da 100644 --- a/crates/ui-egui/src/tests_quit_unsaved.rs +++ b/crates/ui-egui/src/tests_quit_unsaved.rs @@ -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}"); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 7ce11557..552f7695 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`), `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.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:`, ✕ = `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 |