From 6fc33e1ee19849171dff1030f1cc561b79ad0c68 Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 17:34:05 +0200 Subject: [PATCH 1/2] =?UTF-8?q?LR-VIEW-DETAIL:=20the=20loupe=20says=20"Ren?= =?UTF-8?q?dering=E2=80=A6"=20while=20what=20it=20shows=20is=20not=20the?= =?UTF-8?q?=20render=20yet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/ui-egui/src/headless.rs | 51 +++++++++++++++++ crates/ui-egui/src/panels/detail.rs | 89 +++++++++++++++++++++++++++-- docs/background-tasks.md | 5 +- docs/control-protocol.md | 2 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 5 ++ 6 files changed, 147 insertions(+), 7 deletions(-) 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 From 31c31b726b52789b0d2231d1996c90b40b83f22d Mon Sep 17 00:00:00 2001 From: dev-null-undefined Date: Sun, 11 Oct 2026 19:16:10 +0200 Subject: [PATCH 2/2] LR-VIEW-DETAIL: the loupe says when the render behind a stand-in fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A photo whose render failed while the loupe showed its embedded JPEG or a thumbnail kept that stand-in up as if it were the photo; "This photo can't be opened" only showed when nothing at all could be drawn. Now a caution pill at the "Rendering…" spot says so: "Original offline · no smart preview" when that is why, else "This photo can't be opened", with the reason on hover and for screen readers (widget `loupe:renderFailed`). Agents see it as `ui.inspect` → `loupe.failed`, so one waiting for a finished loupe knows when there won't be one. A library change retries failed renders; while the retry of the same key runs, the pill stays and "Rendering…" doesn't take over. The renderer remembers which photo a failure belongs to (`failure_of`): the centre message no longer shows the previous photo's error for a moment after a step. Tests share one render offload (`headless::RenderGate`) that holds renders and can fail chosen slots. Co-Authored-By: Claude --- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/headless.rs | 103 +++++++++++++++++++++++----- crates/ui-egui/src/panels/detail.rs | 68 ++++++++++++++---- crates/ui-egui/src/render.rs | 25 +++++-- docs/control-protocol.md | 7 +- docs/parity-checklist.md | 2 +- docs/whats-new.md | 4 ++ 7 files changed, 173 insertions(+), 38 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index cf2dda385..721ebe49a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -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 diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index a7fded1c7..0dea81150 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -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>, + 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>) -> Self { + Self::failing(open, |_| false) + } + pub(crate) fn failing(open: std::rc::Rc>, 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 { + 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] @@ -3970,23 +4014,8 @@ mod tests { /// 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 crate::render::Slot; 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); @@ -3994,7 +4023,7 @@ mod tests { 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()))); + 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 { @@ -4016,6 +4045,46 @@ mod tests { 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]); diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 6ce9d0ae1..e2c044655 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -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), @@ -759,13 +759,29 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } 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 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)); - if ui.ctx().data_mut(|d| rendering_overdue(d.get_temp_mut_or(egui::Id::new("loupe-rendering"), None), keys, coming, now)) { + 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 }) }; - rendering_pill(ui, canvas, if over.is_positive() { over } else { canvas }, nav); + 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); @@ -1121,21 +1137,49 @@ fn rendering_overdue(wait: &mut RenderingWait, keys: [Option; 4], coming: b *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) { +/// 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, 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(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 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)); - 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); + 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, + 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 { diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index 66bce01ff..dfa4ae3d3 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -197,9 +197,10 @@ pub struct Renderer { stages: HashMap>, /// Slot → key of the last quick job requested for it (each is tried once). quick_tried: HashMap, - /// 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, + /// 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, /// 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 { 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)); } diff --git a/docs/control-protocol.md b/docs/control-protocol.md index b9190d2cb..9d3d192b8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -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:`, ✕ = `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`, `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:`, ✕ = `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 | @@ -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. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill | +| `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 | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index e179277c1..cf188cd65 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` | 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-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 | diff --git a/docs/whats-new.md b/docs/whats-new.md index 7fd189e63..cceb49a29 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -6,6 +6,10 @@ - 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