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
+110 -36
View File
@@ -21,7 +21,11 @@
//! Left-aligned text starts one column after the actual image width, including height-limited images.
//! A blank row separates the bold title from the dimmed author · publication date.
//! Text retains a two-column right gutter; overflowing excerpts end with … without splitting wide glyphs.
//! Both previews share asynchronous full-description fetching and per-video caching.
//! Both previews share an in-memory LRU of 64 full descriptions, keyed by video ID across views.
//! Thumbnails retain eight recently used inactive decoded images plus the selected image.
//! Reopening or revisiting cached videos skips extraction/download/decoding; resize work stays off-thread.
//! Title, author, and publication metadata remain in loaded view rows; preview caches survive view/query changes.
//! Caches last for this session without a timer. A successful r refresh clears both preview buffers.
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
@@ -92,6 +96,18 @@ struct Options {
query: Option<String>,
}
const DESCRIPTION_CACHE_SIZE: usize = 64;
fn cached_description<'a>(
cache: &'a std::collections::VecDeque<(String, String)>,
id: &str,
) -> Option<&'a str> {
cache
.iter()
.find(|(cached, _)| cached == id)
.map(|(_, text)| text.as_str())
}
#[derive(Debug, PartialEq, Eq)]
enum Mode {
Insert,
@@ -150,7 +166,7 @@ impl View {
videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
}
if !videos.is_empty() {
// Borrow IDs before moving rows, retaining existing rows and their description caches.
// Borrow IDs before moving rows, retaining the existing row data.
let keep: Vec<_> = {
let mut ids = HashSet::with_capacity(self.videos.len() + videos.len());
ids.extend(self.videos.iter().map(|video| video.id.as_str()));
@@ -221,6 +237,8 @@ struct App {
description_open: bool,
description_job: Option<(usize, Job)>,
description_error: Option<String>,
// ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows.
descriptions: std::collections::VecDeque<(String, String)>,
description_scroll: u16,
description_max_scroll: u16,
description_viewport: Option<(u16, u16)>,
@@ -251,6 +269,7 @@ impl App {
description_open: false,
description_job: None,
description_error: None,
descriptions: std::collections::VecDeque::with_capacity(DESCRIPTION_CACHE_SIZE),
description_scroll: 0,
description_max_scroll: 0,
description_viewport: None,
@@ -458,6 +477,8 @@ impl App {
self.close_thumbnail();
match config::load(&self.config_path) {
Ok(config) => {
self.thumbnail.clear_cache();
self.descriptions.clear();
self.apply_config(config);
self.load_view();
}
@@ -644,7 +665,12 @@ impl App {
return;
};
let video = &view.videos[index];
if video.description.is_some() {
if let Some(index) = self.descriptions.iter().position(|(id, _)| id == &video.id) {
let hit = self
.descriptions
.remove(index)
.expect("cache position exists");
self.descriptions.push_back(hit);
return;
}
match Job::description(&video.id) {
@@ -653,6 +679,20 @@ impl App {
}
}
fn remember_description(&mut self, id: String, description: String) {
if let Some(index) = self
.descriptions
.iter()
.position(|(cached, _)| cached == &id)
{
self.descriptions.remove(index);
}
if self.descriptions.len() == DESCRIPTION_CACHE_SIZE {
self.descriptions.pop_front();
}
self.descriptions.push_back((id, description));
}
fn play(&mut self) {
let view = &self.views[self.view_index];
let Some(video) = view
@@ -689,9 +729,9 @@ impl App {
.description_job
.take()
.expect("completed description job exists");
if let Some(video) = self.views[self.view_index].videos.get_mut(index) {
if let Some(video) = self.views[self.view_index].videos.get(index) {
match result.and_then(|output| youtube::full_description(&output, &video.id)) {
Ok(description) => video.description = Some(description),
Ok(description) => self.remember_description(video.id.clone(), description),
Err(error) => self.description_error = Some(error),
}
}
@@ -974,7 +1014,7 @@ impl App {
{
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let text = match video.description.as_deref() {
let text = match cached_description(&self.descriptions, &video.id) {
Some("") => "This video has no description.",
Some(description) => description,
None => self
@@ -1065,7 +1105,8 @@ impl App {
excerpt.height -= height + gap;
details.height = height;
frame.render_widget(metadata, details);
let description = match video.description.as_deref() {
let cached = cached_description(&self.descriptions, &video.id);
let description = match cached {
Some("") => "This video has no description.",
Some(description) => description,
None => self
@@ -1074,10 +1115,7 @@ impl App {
.unwrap_or("Loading full description..."),
};
let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim();
let overflow = video
.description
.as_deref()
.is_some_and(|text| !text.is_empty())
let overflow = cached.is_some_and(|text| !text.is_empty())
&& !excerpt.is_empty()
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
frame.render_widget(paragraph, excerpt);
@@ -1312,6 +1350,46 @@ mod tests {
)
}
#[test]
fn descriptions_are_shared_across_views_and_evict_least_recently_used_entries() {
let mut app = app(&["tubular"]);
let output = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Shared video"}]}"#;
for view in &mut app.views {
view.videos = youtube::search_results(output).unwrap().videos;
view.selection.select(Some(0));
view.loaded = true;
}
app.remember_description("jNQXAC9IVRw".into(), "Shared full description".into());
for index in 1..DESCRIPTION_CACHE_SIZE {
app.remember_description(format!("video{index:06}"), format!("Description {index}"));
}
app.switch_view(1);
app.load_description(); // A hit in Feed promotes the Search video's description.
assert!(app.description_job.is_none());
app.remember_description("new00000000".into(), "New description".into());
assert_eq!(
cached_description(&app.descriptions, "jNQXAC9IVRw"),
Some("Shared full description")
);
assert_eq!(cached_description(&app.descriptions, "video000001"), None);
assert_eq!(
cached_description(&app.descriptions, "video000002"),
Some("Description 2")
);
// Updating an existing entry, including a known empty description, must not evict another.
app.remember_description("jNQXAC9IVRw".into(), String::new());
app.load_description();
assert!(app.description_job.is_none());
assert_eq!(
cached_description(&app.descriptions, "jNQXAC9IVRw"),
Some("")
);
assert_eq!(
cached_description(&app.descriptions, "video000002"),
Some("Description 2")
);
}
#[test]
fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() {
use ratatui::{Terminal, backend::TestBackend};
@@ -1329,11 +1407,14 @@ mod tests {
("a界界界界界界界界界", true, Some(63)),
("", true, Some(46)),
] {
app.views[0].videos[0].description = Some(format!(
"{}{last_line}{}",
"line\n".repeat(6),
if hidden { "\nhidden content" } else { "" },
));
app.remember_description(
"jNQXAC9IVRw".into(),
format!(
"{}{last_line}{}",
"line\n".repeat(6),
if hidden { "\nhidden content" } else { "" },
),
);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
@@ -1369,8 +1450,10 @@ mod tests {
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#,
).unwrap().videos;
app.views[0].videos[0].description =
Some(format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)));
app.remember_description(
"jNQXAC9IVRw".into(),
format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)),
);
app.views[0].selection.select(Some(0));
app.thumbnail_open = true;
for (width, height) in [(80, 24), (120, 40), (20, 10)] {
@@ -1464,7 +1547,7 @@ mod tests {
}
#[test]
fn search_pages_preserve_relevance_selection_and_cached_descriptions() {
fn search_pages_preserve_relevance_and_selection() {
let mut view = View::new("Search".into());
let mut first = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#,
@@ -1473,7 +1556,6 @@ mod tests {
first.has_more = true;
view.accept_page(first, false).unwrap();
view.selection.select(Some(1));
view.videos[1].description = Some("Cached full description.".into());
let mut next = youtube::search_results(
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Duplicate"},{"id":"l-GaaivVJCE","title":"Relevant","timestamp":50},{"id":"l-GaaivVJCE","title":"Repeated"},{"id":"aaaaaaaaaaa","title":"Later","timestamp":300}]}"#,
)
@@ -1488,10 +1570,6 @@ mod tests {
["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE", "aaaaaaaaaaa"]
);
assert_eq!(view.videos[1].title, "Second");
assert_eq!(
view.videos[1].description.as_deref(),
Some("Cached full description.")
);
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(view.next_start, 41);
assert!(view.has_more);
@@ -1517,17 +1595,12 @@ mod tests {
assert_eq!(view.videos.last().unwrap().timestamp, Some(200));
assert_eq!(view.pending.len(), 2);
view.selection.select(Some(PAGE_SIZE - 1));
view.videos[PAGE_SIZE - 1].description = Some("Keep this cache.".into());
let second = youtube::feed_results(&format!("{}\n{short}", long_page(180))).unwrap();
view.accept_page(second, true).unwrap();
assert_eq!(view.videos.len(), PAGE_SIZE * 2);
assert_eq!(view.videos.last().unwrap().timestamp, Some(180));
assert_eq!(view.pending.len(), 2);
assert_eq!(view.selection.selected(), Some(PAGE_SIZE - 1));
assert_eq!(
view.videos[PAGE_SIZE - 1].description.as_deref(),
Some("Keep this cache.")
);
view.accept_page(
youtube::feed_results(
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Long last row","timestamp":179}]} {"entries":[]}"#,
@@ -1647,7 +1720,7 @@ mod tests {
app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string()));
assert_eq!(app.views[0].next_start, 21);
app.views[0].selection.select(Some(17));
app.views[0].videos[17].description = Some("Cached.".into());
app.remember_description(app.views[0].videos[17].id.clone(), "Cached.".into());
app.switch_view(1);
app.switch_view(0);
assert_eq!(app.views[0].next_start, 21);
@@ -1657,7 +1730,7 @@ mod tests {
assert!(app.job.is_none());
assert_eq!(app.views[0].videos.len(), PAGE_SIZE);
assert_eq!(
app.views[0].videos[17].description.as_deref(),
cached_description(&app.descriptions, &app.views[0].videos[17].id),
Some("Cached.")
);
assert_eq!(app.views[0].next_start, 21);
@@ -1876,7 +1949,6 @@ mod tests {
uploader_id: None,
timestamp: None,
publication_date: None,
description: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
@@ -1886,7 +1958,6 @@ mod tests {
uploader_id: None,
timestamp: None,
publication_date: None,
description: None,
},
];
app.views[0].selection.select(Some(0));
@@ -2045,7 +2116,10 @@ mod tests {
.unwrap()
.videos;
app.views[1].page_error = Some("Retained page error".into());
app.views[1].videos[1].description = Some("Retained description".into());
app.remember_description(
app.views[1].videos[1].id.clone(),
"Retained description".into(),
);
app.views[3].videos = youtube::feed_results(videos).unwrap().videos;
app.views[3].selection.select(Some(0));
app.views[3].loaded = true;
@@ -2080,7 +2154,7 @@ mod tests {
Some("Retained page error")
);
assert_eq!(
app.views[1].videos[1].description.as_deref(),
cached_description(&app.descriptions, &app.views[1].videos[1].id),
Some("Retained description")
);
assert!(!app.views[4].loaded);
@@ -2358,7 +2432,7 @@ mod tests {
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#,
).unwrap().videos;
app.views[0].videos[0].description = Some("A cached full description.".into());
app.remember_description("jNQXAC9IVRw".into(), "A cached full description.".into());
app.views[0].selection.select(Some(0));
app.views[0].loaded = true;
app.thumbnail_open = true;
+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};
-3
View File
@@ -27,9 +27,6 @@ pub struct Video {
/// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date.
#[serde(default, rename = "upload_date")]
pub publication_date: Option<String>,
// Search descriptions are truncated; only full video metadata populates this cache.
#[serde(skip)]
pub description: Option<String>,
}
#[derive(Deserialize)]