diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 8cb91b9b3..a7fded1c7 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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>, Vec<(Slot, RenderJob)>); + impl RenderOffload for Gate { + fn try_start(&mut self, slot: Slot, job: RenderJob) -> Option { + 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]); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 234476167..6ce9d0ae1 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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 { 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; 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; 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) { + 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). diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 576b3868f..02336c97c 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -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 diff --git a/docs/control-protocol.md b/docs/control-protocol.md index da38cf648..b9190d2cb 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 77ae26e4c..e179277c1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index fb8b285a3..7fd189e63 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -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