feat: retain bounded preview caches across selections and views

This commit is contained in:
2026-10-11 11:56:26 +02:00
parent 755c87f918
commit e5e8799f4a
3 changed files with 188 additions and 46 deletions
+78 -7
View File
@@ -18,6 +18,8 @@ use ratatui_image::{
use crate::youtube::ThumbnailJob;
const CACHE_SIZE: usize = 8;
struct ThumbnailImage {
id: String,
protocol: ThreadProtocol,
@@ -31,8 +33,8 @@ pub struct Thumbnail {
fetch: Option<ThumbnailJob>,
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
image: Option<ThumbnailImage>,
// ponytail: one completed thumbnail plus an in-flight replacement, not a video cache.
cached: Option<ThumbnailImage>,
// ponytail: eight-entry linear lookup; use a keyed LRU if the buffer grows.
cached: std::collections::VecDeque<ThumbnailImage>,
error: Option<String>,
}
@@ -44,7 +46,7 @@ impl Thumbnail {
fetch: None,
decoder: None,
image: None,
cached: None,
cached: std::collections::VecDeque::with_capacity(CACHE_SIZE),
error: None,
}
}
@@ -62,10 +64,16 @@ impl Thumbnail {
if self.selected.as_deref() == Some(id) {
return;
}
// Remove a hit before storing the previous image so eviction cannot discard the target.
let cached = self
.cached
.iter()
.position(|image| image.id == id)
.and_then(|index| self.cached.remove(index));
self.cancel();
self.selected = Some(id.to_owned());
if self.cached.as_ref().is_some_and(|image| image.id == id) {
self.image = self.cached.take();
if let Some(image) = cached {
self.image = Some(image);
return;
}
match ThumbnailJob::start(id) {
@@ -83,10 +91,18 @@ impl Thumbnail {
if let Some(image) = self.image.take()
&& image.complete
{
self.cached = Some(image);
if self.cached.len() == CACHE_SIZE {
self.cached.pop_front();
}
self.cached.push_back(image);
}
}
pub fn clear_cache(&mut self) {
self.cancel();
self.cached.clear();
}
pub fn is_loading(&self) -> bool {
self.selected.is_some()
&& self.error.is_none()
@@ -180,7 +196,6 @@ impl Thumbnail {
// area again before rendering, so rapid resizes cannot draw an old size.
if image.protocol.update_resized_protocol(response) {
image.complete = true;
self.cached = None;
}
}
Ok(Err(error)) => {
@@ -281,6 +296,62 @@ mod tests {
assert!(thumbnail.error.is_none());
}
#[test]
fn thumbnail_hits_survive_a_full_buffer_and_promote_recent_images() {
let mut thumbnail = Thumbnail::new();
let completed = |id: String| {
let (requests, _receiver) = mpsc::channel();
let (_sender, replies) = mpsc::channel();
ThumbnailImage {
id,
protocol: ThreadProtocol::new(
requests,
Some(
Picker::halfblocks()
.new_resize_protocol(image::DynamicImage::new_rgb8(1, 1)),
),
),
replies,
complete: true,
}
};
for index in 0..CACHE_SIZE {
thumbnail.image = Some(completed(format!("video{index:06}")));
thumbnail.cancel();
}
thumbnail.image = Some(completed("video000008".into()));
thumbnail.selected = Some("video000008".into());
// This is the oldest cached image: storing the outgoing image first would evict it.
thumbnail.select("video000000");
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000000");
assert!(thumbnail.fetch.is_none());
thumbnail.select("video000001");
assert_eq!(thumbnail.image.as_ref().unwrap().id, "video000001");
assert!(thumbnail.fetch.is_none());
thumbnail.cancel();
assert!(
!thumbnail
.cached
.iter()
.any(|image| image.id == "video000002")
);
assert!(
thumbnail
.cached
.iter()
.any(|image| image.id == "video000000")
);
assert!(
thumbnail
.cached
.iter()
.any(|image| image.id == "video000008")
);
thumbnail.clear_cache();
assert!(thumbnail.cached.is_empty());
assert!(thumbnail.image.is_none());
}
#[test]
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
use ratatui::{Terminal, backend::TestBackend, style::Color};