LR-VIEW-DETAIL: the loupe says "Rendering…" while what it shows is not the render yet

A raw's embedded JPEG, a thumbnail, the previous render after an edit or the magnified
frame under a 1:1 window all look like the finished photo. Once a render has been on its
way for 0.5 s with nothing new on screen, a pill at the bottom of the photo (widget
`loupe:rendering`) says so; above the Navigator when that is in the way.

The wait starts when the render starts coming (an idle app draws no frames), so a quick
render never shows it, nor does a drag whose drafts keep up; once up it stays until
nothing is coming, so slow drafts don't make it blink. It watches what is on screen: the
After or the Before, and the windows in use. Not an activity row: it says the pixels
aren't final (docs/background-tasks.md).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
dev-null-undefined
2026-10-11 18:50:44 +02:00
co-authored by Claude
parent 76b5377868
commit 6fc33e1ee1
6 changed files with 147 additions and 7 deletions
+51
View File
@@ -3965,6 +3965,57 @@ 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::{RenderOffload, Slot};
use lightcraft_engine::media::{RenderJob, RenderResult};
use std::{cell::Cell, rc::Rc};
/// Renders wait until the gate opens.
struct Gate(Rc<Cell<bool>>, Vec<(Slot, RenderJob)>);
impl RenderOffload for Gate {
fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option<RenderJob> {
if !self.0.get() {
return Some(job);
}
self.1.push((slot, job));
None
}
fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)> {
self.1.drain(..).map(|(slot, job)| (slot, job.run(), 0.0)).collect()
}
}
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(Gate(open.clone(), Vec::new())));
// 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));
}
#[test]
fn screenshot_request_is_answered_without_a_window() {
let mut h = demo([800.0, 500.0]);
+85 -4
View File
@@ -749,7 +749,24 @@ 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));
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));
if ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now)) {
// 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 }) };
rendering_pill(ui, canvas, if over.is_positive() { over } else { canvas }, nav);
}
if let Some(why) = photo.preview_only.as_deref().filter(|_| !fullscreen) {
preview_only_pill(ui, canvas, why);
}
@@ -1039,11 +1056,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 +1090,54 @@ 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
}
/// The pill saying "Rendering…" (registered as `loupe:rendering`), 12 pt above the bottom of `over`,
/// or just above the Navigator when that is in the way. It says the pixels on screen aren't final,
/// so it isn't an activity row (see docs/background-tasks.md).
fn rendering_pill(ui: &egui::Ui, canvas: Rect, over: Rect, navigator: Option<Rect>) {
let t = Tokens::get(ui.ctx());
let p = ui.painter_at(canvas);
let g = p.layout_no_wrap(crate::i18n::tr("Rendering…").to_string(), t.font(12.0), Color32::from_gray(225));
let size = vec2(g.size().x + 20.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));
p.galley(pos2(r.left() + 10.0, r.center().y - g.size().y / 2.0), g, Color32::from_gray(225));
register(ui.ctx(), "loupe:rendering", r);
}
fn quick_name(q: lightcraft_engine::media::QuickSource) -> &'static str {
use lightcraft_engine::media::QuickSource;
match q {
@@ -2448,6 +2508,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).
+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
+1 -1
View File
@@ -72,7 +72,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 |
| `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
@@ -125,7 +125,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) |
| 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 |
+5
View File
@@ -2,6 +2,11 @@
## 2026-10-11
### "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.
### JPEG XL export
- Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI
(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a