mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #2304: LR-VIEW-PHOTOGRID: preserve embedded previews through priority changes and Undo
This commit is contained in:
@@ -554,7 +554,7 @@ pub fn request_thumb(app: &mut LightcraftApp, id: PhotoId, size: usize, priority
|
||||
// has arrived there is nothing left to ask for. Without this the grid would rebuild the render
|
||||
// job — and its cache key — on every frame, which is what the embedded-thumbnail record settles.
|
||||
if let Some(photo) = app.session.catalog.photo(id)
|
||||
&& (app.renderer.thumb_quick_is_final(photo, bucket, priority) || app.renderer.thumb_quick_pending(photo, bucket))
|
||||
&& (app.renderer.thumb_quick_is_final(photo, bucket, priority) || app.renderer.thumb_quick_pending(photo, bucket, priority))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -328,8 +328,13 @@ impl Renderer {
|
||||
if quick && !self.textures.contains_key(&slot) {
|
||||
// Stand-in path (`grid::request_thumb`): the embedded preview is still on its way, or
|
||||
// the real thumbnail is requested at the background priority whatever the caller's.
|
||||
return self.pending.get(&Slot::ThumbQuick(photo.id)).is_some_and(|p| p.0 == key)
|
||||
|| !self.request_needed(slot, key, crate::panels::grid::BACKGROUND_THUMB_PRIORITY);
|
||||
let quick_slot = Slot::ThumbQuick(photo.id);
|
||||
if let Some(&(k, p)) = self.pending.get(&quick_slot)
|
||||
&& k == key
|
||||
{
|
||||
return p == priority.saturating_add(1) || !self.is_queued(quick_slot);
|
||||
}
|
||||
return !self.request_needed(slot, key, crate::panels::grid::BACKGROUND_THUMB_PRIORITY);
|
||||
}
|
||||
!self.request_needed(slot, key, priority)
|
||||
}
|
||||
@@ -344,17 +349,31 @@ impl Renderer {
|
||||
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && !self.request_needed(Slot::ThumbQuick(photo.id), *key, priority)
|
||||
}
|
||||
|
||||
/// Whether this photo's embedded-preview thumbnail is being fetched right now: the grid has
|
||||
/// asked, and neither the picture nor a render behind it is owed.
|
||||
pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize) -> bool {
|
||||
let Some((old, size, _, quick)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
old.as_ptr() == Arc::as_ptr(photo) && *size == bucket && *quick && self.pending.contains_key(&Slot::ThumbQuick(photo.id))
|
||||
/// Whether this photo's embedded-preview thumbnail is pending at the requested priority, or
|
||||
/// already running. A queued priority change must reach the grid's stand-in request.
|
||||
pub fn thumb_quick_pending(&self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, priority: u32) -> bool {
|
||||
let Some((old, size, key, quick)) = self.thumb_inputs.get(&photo.id) else { return false };
|
||||
let slot = Slot::ThumbQuick(photo.id);
|
||||
old.as_ptr() == Arc::as_ptr(photo)
|
||||
&& *size == bucket
|
||||
&& *quick
|
||||
&& self.pending.contains_key(&slot)
|
||||
&& !self.request_needed(slot, *key, priority.saturating_add(1))
|
||||
}
|
||||
|
||||
/// Remember that the camera's embedded preview is this photo's final thumbnail, so the grid's
|
||||
/// per-frame requests stop at it (the quick render's key, which [`Self::thumb_quick_is_final`]
|
||||
/// compares against).
|
||||
pub fn remember_embedded_thumb(&mut self, photo: &Arc<lightcraft_catalog::Photo>, bucket: usize, key: u64) {
|
||||
let slot = Slot::Thumb(photo.id);
|
||||
if self.pending.get(&slot).is_some_and(|(pending_key, _)| *pending_key != key) {
|
||||
// An edit can still be rendering when Undo restores this embedded-final thumbnail.
|
||||
// Retire its full-render identity before it can replace the correct stand-in.
|
||||
self.pending.remove(&slot);
|
||||
self.request_ids.remove(&slot);
|
||||
self.pool.reprioritize(|s, p| if *s == slot { None } else { Some(p) });
|
||||
self.queue.retain(|q| q.slot != slot);
|
||||
}
|
||||
self.remember_thumb(photo, bucket, key, true);
|
||||
self.embedded_thumbs.insert(photo.id, (Arc::downgrade(photo), bucket, key));
|
||||
}
|
||||
@@ -496,6 +515,22 @@ impl Renderer {
|
||||
/// Request a stand-in for `slot` (once per job key).
|
||||
pub fn request_quick(&mut self, slot: Slot, mut job: QuickJob, priority: u32) {
|
||||
if self.quick_tried.get(&slot) == Some(&job.key) {
|
||||
if self.pending.get(&slot).is_some_and(|p| p.0 == job.key && p.1 != priority) {
|
||||
// Scrolling changes a stand-in's priority too. Keep the original closure and
|
||||
// request identity; running or completed work must never be started again.
|
||||
let mut queued = false;
|
||||
self.pool.reprioritize(|s, p| {
|
||||
if *s == slot {
|
||||
queued = true;
|
||||
Some(priority)
|
||||
} else {
|
||||
Some(p)
|
||||
}
|
||||
});
|
||||
if queued && let Some(p) = self.pending.get_mut(&slot) {
|
||||
p.1 = priority;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.quick_tried.insert(slot, job.key);
|
||||
@@ -1118,6 +1153,65 @@ mod thumbnail_tests {
|
||||
r.evict_thumbs(&Default::default(), 600);
|
||||
assert!(r.thumb_inputs.is_empty());
|
||||
}
|
||||
fn queued_quick_thumbnail(id: PhotoId) -> (Arc<Photo>, QuickJob) {
|
||||
let mut session = Session::new();
|
||||
session.media.preview_loader =
|
||||
Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] })));
|
||||
let mut raw = (*photo()).clone();
|
||||
raw.id = id;
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
raw.develop = Arc::new(raw.camera_defaults());
|
||||
session.catalog.apply(Op::AddPhoto { photo: Box::new(raw) }).unwrap();
|
||||
let p = session.catalog.photo(id).unwrap().clone();
|
||||
let job = session.thumb_job(id, 384).unwrap();
|
||||
(p, session.quick_thumb_job(&job).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_standin_promotes_from_prefetch_to_visible_without_restarting() {
|
||||
let (p, quick) = queued_quick_thumbnail(PhotoId(1));
|
||||
let (_, other) = queued_quick_thumbnail(PhotoId(2));
|
||||
let slot = Slot::ThumbQuick(p.id);
|
||||
// Deterministic queue: no native worker starts.
|
||||
let mut r = Renderer { pool: JobPool::new(0), ..Default::default() };
|
||||
r.remember_thumb(&p, 384, quick.key, true);
|
||||
r.request_quick(slot, quick.clone(), 6); // grid prefetch: 5 + 1
|
||||
r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9); // filmstrip: 8 + 1
|
||||
let request_id = r.request_ids[&slot];
|
||||
assert!(r.thumb_current(&p, 384, 5));
|
||||
assert!(!r.thumb_current(&p, 384, 10));
|
||||
r.request_quick(slot, quick.clone(), 11); // visible grid: 10 + 1
|
||||
assert!(r.thumb_current(&p, 384, 10));
|
||||
r.request_quick(slot, quick.clone(), 11);
|
||||
assert_eq!(r.request_ids[&slot], request_id);
|
||||
assert_eq!(r.pool.queued(), 2);
|
||||
r.pool.run_inline(1);
|
||||
let done = r.pool.try_recv().unwrap();
|
||||
assert_eq!(done.slot, slot);
|
||||
assert_eq!(done.result.result.request_id, request_id);
|
||||
assert_eq!(done.result.result.quick, Some(QuickSource::Embedded));
|
||||
// A finished result awaiting collection also must not start another extraction.
|
||||
r.request_quick(slot, quick, 6);
|
||||
assert!(r.thumb_current(&p, 384, 5));
|
||||
assert_eq!(r.pool.queued(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_standin_demotes_when_it_leaves_the_visible_grid() {
|
||||
let (p, quick) = queued_quick_thumbnail(PhotoId(1));
|
||||
let (_, other) = queued_quick_thumbnail(PhotoId(2));
|
||||
let slot = Slot::ThumbQuick(p.id);
|
||||
let mut r = Renderer { pool: JobPool::new(0), ..Default::default() };
|
||||
r.remember_thumb(&p, 384, quick.key, true);
|
||||
r.request_quick(slot, quick.clone(), 11);
|
||||
r.request_quick(Slot::ThumbQuick(PhotoId(2)), other, 9);
|
||||
assert!(!r.thumb_current(&p, 384, 5));
|
||||
r.request_quick(slot, quick, 6);
|
||||
assert!(r.thumb_current(&p, 384, 5));
|
||||
r.pool.run_inline(1);
|
||||
assert_eq!(r.pool.try_recv().unwrap().slot, Slot::ThumbQuick(PhotoId(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failures_retry_after_revision_and_quick_only_is_not_final() {
|
||||
let p = photo();
|
||||
@@ -1258,6 +1352,49 @@ mod thumbnail_tests {
|
||||
std::fs::remove_file(replacement).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_queued_standin_changes_priority_without_restarting() {
|
||||
let mut s = Session::new();
|
||||
s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] })));
|
||||
let mut raw = (*photo()).clone();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
raw.develop = Arc::new(raw.camera_defaults());
|
||||
let (mut app, work, ctx) = fixture_with(s, raw);
|
||||
app.renderer.pool = JobPool::new(0); // deterministic: no quick job starts until run_inline
|
||||
let id = PhotoId(1);
|
||||
let slot = Slot::ThumbQuick(id);
|
||||
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 5);
|
||||
let request_id = app.renderer.request_ids[&slot];
|
||||
assert_eq!(app.renderer.pending[&slot].1, 6);
|
||||
assert_eq!(app.renderer.pool.queued(), 1);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
assert_eq!(app.renderer.pending[&slot].1, 11, "the grid must promote its queued stand-in");
|
||||
assert_eq!(app.renderer.request_ids[&slot], request_id);
|
||||
assert_eq!(app.renderer.pool.queued(), 1);
|
||||
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 5);
|
||||
assert_eq!(app.renderer.pending[&slot].1, 6, "the grid must demote its queued stand-in");
|
||||
assert_eq!(app.renderer.request_ids[&slot], request_id);
|
||||
assert_eq!(app.renderer.pool.queued(), 1);
|
||||
let built = app.renderer.thumb_jobs_built;
|
||||
app.renderer.pool.run_inline(1);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
assert_eq!(app.renderer.pending[&slot].1, 6, "completed work must not be restarted");
|
||||
assert_eq!(app.renderer.request_ids[&slot], request_id);
|
||||
assert_eq!(app.renderer.pool.queued(), 0);
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
assert_eq!(app.renderer.textures[&slot].quick, Some(QuickSource::Embedded));
|
||||
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 5);
|
||||
assert_eq!(app.renderer.thumb_jobs_built, built);
|
||||
assert_eq!(app.renderer.pool.queued(), 0);
|
||||
assert!(!app.renderer.is_pending(Slot::Thumb(id)));
|
||||
assert!(!app.renderer.textures.contains_key(&Slot::Thumb(id)));
|
||||
assert!(work.borrow().jobs.is_empty(), "the final embedded thumbnail needs no real render");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_ready_and_running_frames_build_no_jobs() {
|
||||
let (mut app, work, ctx) = fixture();
|
||||
@@ -1624,6 +1761,74 @@ mod thumbnail_tests {
|
||||
expected.as_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_to_embedded_thumb_rejects_late_full_render() {
|
||||
let mut s = Session::new();
|
||||
s.media.preview_loader = Some(Arc::new(|_, _| Some(lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[200, 100, 50, 255]; 16] })));
|
||||
let mut raw = (*photo()).clone();
|
||||
raw.kind = lightcraft_catalog::MediaKind::Raw;
|
||||
raw.develop = Arc::new(raw.camera_defaults());
|
||||
let (mut app, work, ctx) = fixture_with(s, raw);
|
||||
app.renderer.pool = JobPool::new(0);
|
||||
let id = PhotoId(1);
|
||||
let quick_slot = Slot::ThumbQuick(id);
|
||||
let full_slot = Slot::Thumb(id);
|
||||
let settings_a = app.session.catalog.photo(id).unwrap().develop.clone();
|
||||
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
assert_eq!(app.renderer.pool.run_inline(1), 1);
|
||||
app.renderer.poll(&ctx, &mut app.session);
|
||||
let (key_a, pixels_a) = {
|
||||
let shown = app.renderer.thumb(id).unwrap();
|
||||
assert_eq!(shown.quick, Some(QuickSource::Embedded));
|
||||
(shown.key, shown.pixels.as_ref().unwrap().pixels.clone())
|
||||
};
|
||||
assert!(app.renderer.textures.contains_key(&quick_slot));
|
||||
assert!(!app.renderer.textures.contains_key(&full_slot));
|
||||
assert!(work.borrow().jobs.is_empty());
|
||||
|
||||
let mut settings_b = (*settings_a).clone();
|
||||
settings_b.light.exposure += 0.5;
|
||||
app.session.commit("Exposure", Op::SetDevelop { id, settings: Arc::new(settings_b), label: "Exposure".into(), edited: None }).unwrap();
|
||||
assert!(!app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap()));
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
// Manual owns dispatched jobs: hold B until the real Undo command restores A.
|
||||
let (slot, held_b) = take_job(&work);
|
||||
assert_eq!(slot, full_slot);
|
||||
let key_b = held_b.key;
|
||||
assert_ne!(key_b, key_a);
|
||||
assert!(app.renderer.is_pending(full_slot));
|
||||
|
||||
app.run("edit.undo", serde_json::json!({})).unwrap();
|
||||
assert_eq!(app.session.catalog.photo(id).unwrap().develop.as_ref(), settings_a.as_ref());
|
||||
assert!(app.session.embedded_thumb_is_final(app.session.catalog.photo(id).unwrap()));
|
||||
crate::panels::grid::request_thumb(&mut app, id, 384, 10);
|
||||
{
|
||||
let restored = app.renderer.thumb(id).unwrap();
|
||||
assert_eq!(restored.key, key_a);
|
||||
assert_eq!(restored.pixels.as_ref().unwrap().pixels, pixels_a);
|
||||
assert_eq!(restored.quick, Some(QuickSource::Embedded));
|
||||
}
|
||||
assert!(work.borrow().jobs.is_empty(), "restoring embedded-final A needs no full render");
|
||||
assert_eq!(app.renderer.pool.queued(), 0);
|
||||
|
||||
let result_b = held_b.run();
|
||||
assert_eq!(result_b.key, key_b);
|
||||
let rendered_b = result_b.rendered.as_ref().expect("held edited thumbnail must render before the stale-result oracle");
|
||||
assert_ne!(color_image(&rendered_b.image).pixels, pixels_a, "the held result must be distinguishable from A");
|
||||
finish(&mut app, &work, &ctx, slot, result_b);
|
||||
|
||||
let shown = app.renderer.thumb(id).unwrap();
|
||||
assert_eq!(shown.key, key_a, "late edited Thumb(B) must not replace restored embedded ThumbQuick(A)");
|
||||
assert_eq!(shown.pixels.as_ref().unwrap().pixels, pixels_a);
|
||||
assert_eq!(shown.quick, Some(QuickSource::Embedded));
|
||||
let quick = app.renderer.textures.get(&quick_slot).expect("the correct embedded thumbnail must remain installed");
|
||||
assert_eq!(quick.key, key_a);
|
||||
assert_eq!(quick.quick, Some(QuickSource::Embedded));
|
||||
assert_eq!(quick.pixels.as_ref().unwrap().pixels, pixels_a);
|
||||
assert!(!app.renderer.is_pending(full_slot));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -125,7 +125,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
|
||||
| Id | Feature | Tier | Status | Evidence | Missing / notes |
|
||||
|---|---|---|---|---|---|
|
||||
| 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; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report) |
|
||||
| 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; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report); queued embedded thumbnail stand-ins follow viewport priority changes without another extraction (`crates/ui-egui/src/render.rs`, issue #348; full-app visual/timing verification pending) |
|
||||
| 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). 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` | |
|
||||
@@ -885,6 +885,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged |
|
||||
| 2026-10-11 | minor | LR-IMP-FORMATS: EOS R6 Mark III CR3 opens with its as-shot white balance and black level (`ColorData` version 66); status unchanged (🟡) |
|
||||
| 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) |
|
||||
| 2026-10-10 | minor | LR-VIEW-PHOTOGRID: retain queued stand-in identity while updating visible/prefetch priority; status unchanged. |
|
||||
| 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. |
|
||||
| 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` |
|
||||
| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 97 Canon CR2, Pentax and Ricoh Adobe models verified on one CC0 file each; status unchanged (🟡) |
|
||||
|
||||
Reference in New Issue
Block a user