mirror of
https://github.com/storytold/lightcraft.git
synced 2026-11-02 06:27:53 -06:00
@@ -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
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user