# Conflicts:
#	docs/whats-new.md
This commit is contained in:
bflatastic
2026-10-13 11:01:46 +07:00
8 changed files with 289 additions and 15 deletions
+2
View File
@@ -96,6 +96,8 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value {
"photo": p.0,
"source": src,
"pending": app.renderer.is_pending(crate::render::Slot::Main),
// why its render failed (the loupe then shows a stand-in, or nothing, for good)
"failed": app.renderer.failure_of(crate::render::Slot::Main, p),
// zoomed past the whole-frame render: the window rendered at no more than 100 % (pixels of its frame)
"region": app.region_view.map(|r| json!({"full": [r.full.0, r.full.1], "window": [r.window.x, r.window.y, r.window.w, r.window.h], "pending": app.renderer.is_pending(crate::render::Slot::Region)})),
// …and the same for the Before side of a Before/After view
+120
View File
@@ -450,6 +450,50 @@ impl Headless {
}
}
/// For tests, a render offload: renders wait (stay queued) until `open` is set, then run; those
/// whose slot `fails` picks come back failed, as an undecodable raw's would. Install it with
/// `h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone())))`.
#[cfg(test)]
pub(crate) struct RenderGate {
open: std::rc::Rc<std::cell::Cell<bool>>,
fails: fn(crate::render::Slot) -> bool,
jobs: Vec<(crate::render::Slot, lightcraft_engine::media::RenderJob)>,
}
#[cfg(test)]
impl RenderGate {
pub(crate) fn new(open: std::rc::Rc<std::cell::Cell<bool>>) -> Self {
Self::failing(open, |_| false)
}
pub(crate) fn failing(open: std::rc::Rc<std::cell::Cell<bool>>, fails: fn(crate::render::Slot) -> bool) -> Self {
Self { open, fails, jobs: Vec::new() }
}
}
#[cfg(test)]
impl crate::render::RenderOffload for RenderGate {
fn try_start(&mut self, slot: crate::render::Slot, job: lightcraft_engine::media::RenderJob) -> Option<lightcraft_engine::media::RenderJob> {
if !self.open.get() {
return Some(job);
}
self.jobs.push((slot, job));
None
}
fn finished(&mut self) -> Vec<(crate::render::Slot, lightcraft_engine::media::RenderResult, f64)> {
let fails = self.fails;
self.jobs
.drain(..)
.map(|(slot, job)| {
let mut r = job.run();
if fails(slot) {
r.rendered = Err("no decoder for this raw".into());
}
(slot, r, 0.0)
})
.collect()
}
}
#[cfg(test)]
mod tests {
#[test]
@@ -4049,6 +4093,82 @@ mod tests {
assert!(!h.app.widgets.iter().any(|(w, _)| w == "canvas:navigator"), "toggled off");
}
/// While an edit renders, the loupe shows the last render: it says "Rendering…" once that has
/// lasted a moment (not at once after a spell away from the loupe, when no frames were drawn),
/// and stops when the render lands.
#[test]
fn the_loupe_says_rendering_while_an_edit_renders() {
use crate::render::Slot;
use std::{cell::Cell, rc::Rc};
let pill = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "loupe:rendering");
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert!(!pill(&h), "a finished render");
let open = Rc::new(Cell::new(false));
h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone())));
// half a second in the grid: the loupe draws no frames
h.request("ui.set", json!({"view": "photoGrid"}), t);
for _ in 0..30 {
h.step();
}
h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), t);
h.request("ui.set", json!({"view": "detail"}), t);
for _ in 0..12 {
h.step();
}
assert!(h.app.renderer.is_pending(Slot::Main), "the edit's render is held");
assert!(!pill(&h), "not at once");
for _ in 0..30 {
h.step();
}
assert!(pill(&h), "the last render is on screen and the edit's is on its way");
open.set(true);
assert!(h.step_until(SETTLE, |h| !pill(h)), "gone once the render lands");
assert!(!h.app.renderer.is_pending(Slot::Main));
}
/// A render that fails while the loupe shows a stand-in (the embedded JPEG, a thumbnail) left
/// the stand-in up as if it were the photo: the loupe says the photo can't be opened, with the
/// error on hover, and keeps saying so while a library change retries it.
#[test]
fn the_loupe_says_so_when_the_render_behind_a_stand_in_fails() {
use crate::render::Slot;
use std::{cell::Cell, rc::Rc};
let has = |h: &Headless, id: &str| h.app.widgets.iter().any(|(w, _)| w == id);
let mut h = demo([1000.0, 700.0]);
let t = Duration::from_secs(10);
h.request("ui.set", json!({"view": "detail", "right": "none"}), t);
h.settle(SETTLE);
assert!(!has(&h, "loupe:renderFailed"));
let first = h.app.session.active().expect("a photo is open").0;
// every loupe render fails, as an undecodable raw's would; the rest render
let open = Rc::new(Cell::new(true));
h.app.renderer.set_offload(Box::new(super::RenderGate::failing(open.clone(), |s| s == Slot::Main)));
let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo").0;
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other], "active": other}}), t);
assert!(h.step_until(SETTLE, |h| has(h, "loupe:renderFailed")), "the failure is said");
let shown = h.app.loupe_shown.map(|l| l.1);
assert!(!matches!(shown, Some("render" | "none")), "over a stand-in: {shown:?}");
assert!(!has(&h, "loupe:rendering"));
let r = h.request("ui.inspect", json!({}), t);
assert_eq!(r["result"]["loupe"]["failed"], "no decoder for this raw", "{}", r["result"]["loupe"]);
// a library change (here a rating) retries it: the failure stays said, no "Rendering…"
open.set(false);
h.request("engine.execute", json!({"command": "photo.rate", "params": {"rating": 2}}), t);
for _ in 0..45 {
h.step();
}
assert!(h.app.renderer.is_pending(Slot::Main), "the retry is held");
assert!(has(&h, "loupe:renderFailed") && !has(&h, "loupe:rendering"), "no blink while it retries");
// back on a photo that rendered: nothing to say
open.set(true);
h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [first], "active": first}}), t);
h.app.renderer.set_offload(Box::new(super::RenderGate::new(open.clone())));
assert!(h.step_until(SETTLE, |h| h.app.loupe_shown.is_some_and(|l| l.1 == "render") && !has(h, "loupe:renderFailed")));
}
#[test]
fn screenshot_request_is_answered_without_a_window() {
let mut h = demo([800.0, 500.0]);
+130 -5
View File
@@ -623,7 +623,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
} else {
shown = draw(Slot::Main, img_rect, canvas);
if shown == "none" {
match app.renderer.failure(Slot::Main) {
match app.renderer.failure_of(Slot::Main, id) {
Some(e) => {
p.text(
canvas.center() - vec2(0.0, 10.0),
@@ -749,7 +749,40 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
}
}
// drawn and hit-tested above the loupe and its tools: clicks on it pan
navigator(app, ui, canvas, img_rect, id);
let nav = navigator(app, ui, canvas, img_rect, id);
// what is on screen isn't the render on its way (a stand-in, the last render after an edit,
// a magnified frame under its window): say so once that has lasted a moment
let whole: &[Slot] = if show_before && !split {
&[Slot::Before]
} else if split || split_view {
&[Slot::Main, Slot::Before]
} else {
&[Slot::Main]
};
let coming = !matches!(shown, "none" | "hover")
&& whole.iter().chain(windows.iter().map(|(v, _)| &v.slot)).any(|s| app.renderer.is_pending(*s) && !app.renderer.retrying(*s));
let keys =
[Slot::Main, Slot::Before, Slot::Region, Slot::RegionBefore].map(|s| app.renderer.textures.get(&s).filter(|t| t.photo == id).map(|t| t.key));
let rendering = ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now));
// the render failed while a stand-in is up: what is on screen is all there will be (also while
// a library change retries it)
let failed = (!rendering
&& !(show_before && !split)
&& !matches!(shown, "render" | "none" | "hover")
&& (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main)))
.then(|| app.renderer.failure_of(Slot::Main, id))
.flatten();
if rendering || failed.is_some() {
// over the photo (the After in two views); in the crop tool's wide margin, under it
let over = if crop_tool { canvas } else { img_rect.intersect(if split { main_area } else { canvas }) };
let over = if over.is_positive() { over } else { canvas };
match failed {
Some(e) => failed_pill(ui, app, &photo, canvas, over, nav, "loupe:renderFailed", e),
None => {
loupe_pill(ui, canvas, over, nav, "loupe:rendering", crate::i18n::tr("Rendering…"), false);
}
}
}
if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) {
preview_only_pill(ui, canvas, why);
}
@@ -1039,11 +1072,11 @@ fn pretty_date(iso: &str) -> String {
const NAV_W: f32 = 180.0;
/// The Navigator: while zoomed in, a mini map of the photo at the canvas' bottom right with the
/// visible region outlined; click or drag on it to pan.
fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) {
/// visible region outlined; click or drag on it to pan. Returns the area it covers.
fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect, id: PhotoId) -> Option<Rect> {
let zoomed = img.width() > canvas.width() + 1.0 || img.height() > canvas.height() + 1.0;
if !app.ui.navigator || !zoomed || app.ui.fullscreen {
return;
return None;
}
let tex = app.renderer.thumb(id).or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id));
let aspect = img.width() / img.height().max(1.0);
@@ -1073,11 +1106,82 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect
let v = ((q.y - frame.top()) / frame.height()).clamp(0.0, 1.0);
app.ui.pan = (u, v);
}
Some(frame.expand(5.0))
}
/// Neighbour prefetch: below on-screen thumbnails, above background thumbnail refreshes.
const PREFETCH_PRIORITY: u32 = 4;
/// How long a render may be on its way, with nothing new on screen, before the loupe says
/// "Rendering…" (s): as long as the activity stack waits before it shows a task.
const RENDERING_AFTER_SECS: f64 = 0.5;
/// What [`rendering_overdue`] keeps between frames while a render is coming: the texture keys on
/// screen, since when, and whether the pill is up.
type RenderingWait = Option<([Option<u64>; 4], f64, bool)>;
/// Whether the loupe says "Rendering…": a render has been `coming` for [`RENDERING_AFTER_SECS`] with
/// no new texture `keys` on screen. The wait starts when the render starts coming (an idle app draws
/// no frames, so the last one may be long ago), and once up the pill stays until nothing is coming:
/// drafts slower than the wait don't make it blink.
fn rendering_overdue(wait: &mut RenderingWait, keys: [Option<u64>; 4], coming: bool, now: f64) -> bool {
if !coming {
*wait = None;
return false;
}
let (seen, since, up) = wait.get_or_insert((keys, now, false));
if !*up && *seen != keys {
(*seen, *since) = (keys, now);
}
*up |= now - *since > RENDERING_AFTER_SECS;
*up
}
/// A pill over the photo, 12 pt above the bottom of `over` or just above the Navigator when that
/// is in the way, registered as `id`; `caution` adds the preview-only pill's caution icon. The
/// loupe's "Rendering…" says the pixels on screen aren't final, so it isn't an activity row (see
/// docs/background-tasks.md); "This photo can't be opened" says the stand-in on screen is all
/// there will be.
fn loupe_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option<Rect>, id: &str, text: &str, caution: bool) -> Rect {
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let g = p.layout_no_wrap(text.to_string(), t.font(12.0), Color32::from_gray(225));
let inset = if caution { 29.0 } else { 10.0 };
let size = vec2(g.size().x + inset + 10.0, 20.0);
let mut r = Rect::from_center_size(pos2(over.center().x, over.bottom() - 12.0 - size.y / 2.0), size);
if let Some(nav) = navigator.filter(|n| n.expand(8.0).intersects(r)) {
r = Rect::from_min_size(pos2(nav.right() - size.x, nav.top() - 8.0 - size.y), size);
}
p.rect_filled(r, 10.0, Color32::from_black_alpha(185));
if caution {
crate::icons::paint(&p, Rect::from_center_size(pos2(r.left() + 16.0, r.center().y), vec2(13.0, 13.0)), crate::icons::Icon::Info, t.caution);
}
p.galley(pos2(r.left() + inset, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
register(ui.ctx(), id, r);
r
}
/// The caution pill saying a render failed behind the stand-in on screen (see [`loupe_pill`]):
/// "Original offline · no smart preview" when that is why, else "This photo can't be opened", with
/// the error on hover and for screen readers.
#[allow(clippy::too_many_arguments)]
pub(crate) fn failed_pill(
ui: &egui::Ui,
app: &LightcraftApp,
photo: &lightcraft_catalog::Photo,
canvas: Rect,
over: Rect,
navigator: Option<Rect>,
id: &str,
error: &str,
) {
let offline = matches!(&photo.source, lightcraft_catalog::Source::File { path } if app.session.media.availability.is_offline(path));
let text = crate::i18n::tr(if offline { "Original offline · no smart preview" } else { "This photo can't be opened" });
let r = loupe_pill(ui, canvas, over, navigator, id, text, true);
let resp = ui.interact(r, egui::Id::new(("render-failed", id)), Sense::hover()).on_hover_text(error);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, format!("{text}: {error}")));
}
fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
use lightcraft_engine::media::QuickSource;
match q {
@@ -2448,6 +2552,27 @@ mod tests {
assert!(outside_crop_mesh(Rect::NAN, &box_at(pos2(50.0, 50.0), 20.0, 20.0, 10.0), Color32::BLACK).indices.is_empty());
}
/// A raw's embedded JPEG shown while the raw decodes, or the last render while an edit renders,
/// looked like the finished photo: the loupe says "Rendering…" once a render has been coming for
/// a moment with nothing new on screen, and doesn't blink while slow drafts arrive.
#[test]
fn the_loupe_says_rendering_when_the_screen_falls_behind() {
use super::{RENDERING_AFTER_SECS as WAIT, rendering_overdue};
let key = |k| [Some(k), None, None, None];
let mut wait = None;
assert!(!rendering_overdue(&mut wait, key(1), false, 0.0), "nothing coming");
// an edit after an idle spell: the wait starts now, not at the last frame drawn
assert!(!rendering_overdue(&mut wait, key(1), true, 30.0), "a render just asked for");
assert!(!rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 0.8), "a quick render never shows it");
assert!(rendering_overdue(&mut wait, key(1), true, 30.0 + WAIT * 1.2), "nothing new for the wait");
assert!(rendering_overdue(&mut wait, key(2), true, 31.0 + WAIT), "once up, a slow draft doesn't blink it");
assert!(!rendering_overdue(&mut wait, key(3), false, 32.0), "done");
// a drag whose drafts keep up: always something coming, a new draft every 0.1 s
for i in 1..30u32 {
assert!(!rendering_overdue(&mut wait, key(u64::from(i)), true, 40.0 + f64::from(i) * 0.1), "draft {i}");
}
}
/// Issue #652: a native host reports the GPU's texture limit in the first frame's input only.
/// The loupe must still know it in every later frame (it read the raw input, found nothing and
/// cut every window render to 2048 px).
+20 -5
View File
@@ -197,9 +197,10 @@ pub struct Renderer {
stages: HashMap<Slot, Arc<StageCache>>,
/// Slot → key of the last quick job requested for it (each is tried once).
quick_tried: HashMap<Slot, u64>,
/// Jobs that failed (unreadable or missing file…), by slot and job key, with the error: not
/// requested again until the key changes (an edit, another size) — the grid asks every frame.
failed: HashMap<Slot, (u64, String, u64)>,
/// Jobs that failed (unreadable or missing file…), by slot and job key, with the error and
/// the photo: not requested again until the key changes (an edit, another size) — the grid
/// asks every frame.
failed: HashMap<Slot, (u64, String, u64, PhotoId)>,
/// The catalog revision seen at the last poll: a failure is retried once the catalog changes
/// (a relinked or re-imported file).
catalog_rev: u64,
@@ -541,6 +542,18 @@ impl Renderer {
self.failed.get(&slot).map(|f| f.1.as_str())
}
/// Why the last render of `photo` for `slot` failed: a view slot shows one photo after
/// another, and another photo's error isn't this one's.
pub fn failure_of(&self, slot: Slot, photo: PhotoId) -> Option<&str> {
self.failed.get(&slot).filter(|f| f.3 == photo).map(|f| f.1.as_str())
}
/// Whether `slot`'s pending render asks again for the key that last failed (a library change
/// retries failures): what is on screen stays what the failure left until it answers.
pub fn retrying(&self, slot: Slot) -> bool {
self.pending.get(&slot).zip(self.failed.get(&slot)).is_some_and(|(p, f)| p.0 == f.0)
}
/// The slots with a render pending (diagnostics: `ui.inspect`).
pub fn pending_slots(&self) -> Vec<String> {
self.pending.keys().map(|s| format!("{s:?}")).collect()
@@ -700,7 +713,7 @@ impl Renderer {
Err(e) => {
if !matches!(slot, Slot::Prefetch(_)) {
log::warn!("render {slot:?}: {e}");
self.failed.insert(slot, (r.key, e, self.catalog_rev));
self.failed.insert(slot, (r.key, e, self.catalog_rev, r.photo));
}
continue;
}
@@ -947,8 +960,10 @@ mod thumbnail_tests {
r.remember_thumb(&p, 384, 77, false);
r.pending.insert(Slot::ThumbQuick(p.id), (77, 11));
assert!(!r.thumb_current(&p, 384, 10));
r.failed.insert(slot, (77, "no decoder".into(), 0));
r.failed.insert(slot, (77, "no decoder".into(), 0, p.id));
assert!(r.thumb_current(&p, 384, 10));
assert_eq!(r.failure_of(slot, p.id), Some("no decoder"));
assert_eq!(r.failure_of(slot, PhotoId(p.id.0 + 1)), None, "another photo's error");
r.catalog_rev = 1;
assert!(!r.thumb_current(&p, 384, 10));
}
+4 -1
View File
@@ -9,7 +9,10 @@ a task that can stop is running. Agents see the same list through `activity.list
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.
dialog counts files while it scans), and a toast at the end saying what happened is fine. A view saying its pixels
aren't final isn't progress either: the loupe's "Rendering…" pill (`loupe:rendering`) marks a stand-in or an
out-of-date render on the photo itself, and the render behind it holds no row (a slider drag replaces it many times a
second).
## Adding a task
+4 -3
View File
@@ -44,7 +44,8 @@ their command behavior. That API invokes the app command dispatcher directly; it
Cocoa menu dispatch. Native menu activation and keyboard accelerator delivery require a separate test.
Before capturing a finished Detail view, poll `ui.inspect` until the expected `loupe.photo` is active,
`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty.
`loupe.source` is `render` or `cached`, `loupe.pending` is false, and the render queues are empty — or until
`loupe.failed` is set: that photo's render failed, and what is on screen is all there will be.
For the loupe, `cached` is a prior view render keyed by the current photo and develop settings;
`thumb`, `small` and `embedded` are separate fallback sources. A presented frame can otherwise
contain an in-progress rendering placeholder. Require loaded thumbnail textures only when the
@@ -58,7 +59,7 @@ filmstrip is visible.
| `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: 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.inspect` | — | UI state, window, `loupe` (`source`, `pending`, `failed` = why its render failed, else `null`, 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 |
@@ -72,7 +73,7 @@ filmstrip is visible.
| `ui.dialog.confirm` / `ui.dialog.cancel` | — | Close the open dialog |
| `ui.resize` | `{width, height}` | Resize the window |
| `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 |
| `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. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in |
| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); 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 |
+1 -1
View File
@@ -129,7 +129,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|---|---|---|---|---|---|
| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own |
| LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid |
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | |
| LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) |
| LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | |
| LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen |
| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid |
+8
View File
@@ -35,6 +35,14 @@
- Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and
ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come
out green and dark.
### "Rendering…" on the photo
- The loupe says "Rendering…" at the bottom of the photo when what it shows isn't the finished photo yet: the
camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame
while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it.
- If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has
its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo ("Original offline · no
smart preview" or "This photo can't be opened", the reason on hover) instead of showing that preview as if it were
the photo.
### JPEG XL export
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI