Thumbnails: keep textures when the disk cache is resized

Changing the disk cache size (library.preferences cacheMb) re-attached a
new PreviewCache object, and the renderer treats a new cache object as a
library reset: every texture, the loupe included, was dropped and
re-rendered. Thumbnails are keyed by content, so a new budget leaves them
valid. The budget now changes in place on the attached cache (same object,
same generation); the next write trims to it, as before. A preview clear,
a generation bump and a new library still invalidate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
radialmonster
2026-10-07 19:04:03 -04:00
co-authored by Claude Opus 5.5
parent c7f5664ce2
commit 73cf0d50bb
5 changed files with 88 additions and 8 deletions
+4 -3
View File
@@ -557,13 +557,14 @@ impl Session {
if self.cache_mb == 0 { crate::media::DISK_CACHE_BYTES } else { u64::from(self.cache_mb) << 20 }
}
/// Change the thumbnail disk cache budget (MB, 0 = default): re-attaches the cache, which
/// trims it to the new size. Saved with the library preferences.
/// Change the thumbnail disk cache budget (MB, 0 = default) in place: the cached thumbnails
/// stay (the next write trims to the new size), and so do the textures shown from them.
/// Saved with the library preferences.
pub fn set_cache_mb(&mut self, mb: u32) -> Result<()> {
self.cache_mb = mb;
if let Some(lib) = self.library.as_ref().filter(|l| l.on_disk) {
let dir = lib.thumbs_dir();
self.media.attach_disk_cache(&dir, self.cache_bytes());
self.media.set_disk_cache_bytes(&dir, self.cache_bytes());
}
self.save_prefs()
}
+10
View File
@@ -249,6 +249,16 @@ impl MediaCache {
self.rendered = Arc::new(PreviewCache::with_disk(rendered_budget(self.budget), dir, disk_bytes));
}
/// Change the disk budget of the cache in `dir`: in place when that cache is attached (its
/// thumbnails are keyed by content, so they and the textures shown from them stay valid,
/// and the same cache object keeps its generation), else attach it.
pub fn set_disk_cache_bytes(&mut self, dir: &std::path::Path, disk_bytes: u64) {
match self.rendered.disk().filter(|d| d.dir() == dir) {
Some(d) => d.set_budget(disk_bytes),
None => self.attach_disk_cache(dir, disk_bytes),
}
}
/// Forget every decoded source (photo ids changed meaning, e.g. another library was opened).
pub fn clear_sources(&mut self) {
self.settings_hashes = SettingsHashes::default();
+25
View File
@@ -2,6 +2,7 @@
//! persistence in prefs.json.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use lightcraft_catalog::MediaKind;
use lightcraft_develop::Preset;
@@ -263,3 +264,27 @@ fn curve_presets_save_apply_export_import_and_persist() {
assert_eq!(names, ["Faded Red", "Linear (imported)"]);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() {
let lib = temp_dir("resize");
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let cache = s.media.rendered.clone();
let key = lightcraft_preview::hash_bytes(b"thumbnail");
let img = lightcraft_raster::Rgba8 { width: 4, height: 4, data: vec![[10, 20, 30, 255]; 16] };
cache.put(key, Arc::new(img));
let generation = cache.generation();
s.execute("library.preferences", &json!({"cacheMb": 300})).unwrap();
// the same cache object (the UI keeps its textures), the same generation, the new budget
assert!(Arc::ptr_eq(&cache, &s.media.rendered));
assert_eq!(s.media.rendered.generation(), generation);
assert_eq!(s.media.rendered.disk().unwrap().budget(), 300 << 20);
assert!(s.media.rendered.get(key).is_some());
// an explicit clear still invalidates
s.execute("library.clearPreviews", &json!({})).unwrap();
assert_ne!(s.media.rendered.generation(), generation);
assert!(s.media.rendered.get(key).is_none());
drop(s);
let _ = std::fs::remove_dir_all(&lib);
}
+16 -4
View File
@@ -22,7 +22,8 @@ const QUALITY: u8 = 90;
pub struct DiskCache {
dir: PathBuf,
budget: u64,
/// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]).
budget: AtomicU64,
/// Bytes on disk (`None` until first scanned).
total: Mutex<Option<u64>>,
pub hits: AtomicU64,
@@ -34,7 +35,7 @@ impl DiskCache {
pub fn new(dir: &Path, budget: u64) -> DiskCache {
DiskCache {
dir: dir.to_path_buf(),
budget,
budget: AtomicU64::new(budget),
total: Mutex::new(None),
hits: AtomicU64::new(0),
misses: AtomicU64::new(0),
@@ -46,6 +47,17 @@ impl DiskCache {
&self.dir
}
/// Bytes the cache may hold.
pub fn budget(&self) -> u64 {
self.budget.load(Ordering::Relaxed)
}
/// Change the budget in place: the files stay valid (they are keyed by content), so a resize
/// keeps them; a smaller budget is enforced by the next write, like any other overflow.
pub fn set_budget(&self, bytes: u64) {
self.budget.store(bytes, Ordering::Relaxed);
}
fn path(&self, key: Hash128) -> PathBuf {
let hex = key.to_string();
self.dir.join(&hex[..2]).join(format!("{hex}.jpg"))
@@ -94,7 +106,7 @@ impl DiskCache {
let mut t = self.total.lock().unwrap_or_else(|e| e.into_inner());
let total = t.get_or_insert(0);
*total += bytes.len() as u64;
*total > self.budget
*total > self.budget()
};
if over {
self.prune();
@@ -134,7 +146,7 @@ impl DiskCache {
pub fn prune(&self) {
let mut files = self.scan();
let mut total: u64 = files.iter().map(|f| f.1).sum();
let target = self.budget / 5 * 4;
let target = self.budget() / 5 * 4;
files.sort_by_key(|f| f.2);
for (p, len, _) in files {
if total <= target {
+33 -1
View File
@@ -692,7 +692,9 @@ mod thumbnail_tests {
}
}
fn fixture() -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
let mut s = Session::new();
fixture_with(Session::new())
}
fn fixture_with(mut s: Session) -> (crate::LightcraftApp, std::rc::Rc<std::cell::RefCell<Work>>, egui::Context) {
s.media.file_loader = Some(Arc::new(|_, _| {
let mut image = lightcraft_raster::Rgb32f::new(8, 8);
image.data.fill([0.3, 0.2, 0.1]);
@@ -748,6 +750,36 @@ mod thumbnail_tests {
assert_eq!(app.renderer.queue.len(), 1);
}
#[test]
fn resizing_the_disk_cache_keeps_textures_and_skips_rebuilds() {
let lib = std::env::temp_dir().join(format!("lc-ui-thumb-resize-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&lib);
let mut s = Session::new().with_fs();
s.open_library(&lib, false).unwrap();
let (mut app, work, ctx) = fixture_with(s);
request(&mut app);
let (slot, job) = take_job(&work);
finish(&mut app, &work, &ctx, slot, job.run());
assert!(app.renderer.thumb(PhotoId(1)).is_some());
let cache = app.session.media.rendered.clone();
app.session.execute("library.preferences", &serde_json::json!({"cacheMb": 300})).unwrap();
app.renderer.poll(&ctx, &mut app.session);
// thumbnails are keyed by content: a new budget leaves them (and the cache) valid
assert!(app.renderer.thumb(PhotoId(1)).is_some());
assert!(Arc::ptr_eq(&cache, &app.session.media.rendered));
for _ in 0..30 {
request(&mut app);
}
assert_eq!(app.renderer.thumb_jobs_built, 1);
assert!(work.borrow().jobs.is_empty());
// an explicit clear still drops them
app.session.execute("library.clearPreviews", &serde_json::json!({})).unwrap();
app.renderer.poll(&ctx, &mut app.session);
assert!(app.renderer.thumb(PhotoId(1)).is_none());
drop(app);
let _ = std::fs::remove_dir_all(&lib);
}
#[test]
fn closing_import_review_cancels_queued_and_running_identities() {
let (mut app, work, ctx) = fixture();