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 <noreply@anthropic.com>
This commit is contained in:
Andrey Vyrodov 2026-10-09 23:10:36 +02:00
parent 4e323ad26f
commit af9b1063ca
24 changed files with 361 additions and 20 deletions

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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}"
}
" — 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"
}

View File

@ -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"
}

View File

@ -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}件"
}

View File

@ -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": "表示を減らす"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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}"
}

View File

@ -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": "Свернуть"
}

View File

@ -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} ще"
}

View File

@ -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": "Показати менше"
}

View File

@ -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} 项"
}

View File

@ -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": "收起"
}

View File

@ -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} 項"
}

View File

@ -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": "收合"
}

View File

@ -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),

View File

@ -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<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 +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);

View File

@ -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 });
}
}

View File

@ -1,5 +1,6 @@
//! Window regions and panels.
pub mod activity;
pub mod bottombar;
pub mod chips;
pub mod compare;

View File

@ -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:?}");
}

View File

@ -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:<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 |