From e5e8799f4a02add49b2837bc1a91a1e9a68d9e20 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 11:56:26 +0200 Subject: [PATCH] feat: retain bounded preview caches across selections and views --- src/main.rs | 146 +++++++++++++++++++++++++++++++++++------------ src/thumbnail.rs | 85 ++++++++++++++++++++++++--- src/youtube.rs | 3 - 3 files changed, 188 insertions(+), 46 deletions(-) diff --git a/src/main.rs b/src/main.rs index 8cf8536..c4808f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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, } +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, + // 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; diff --git a/src/thumbnail.rs b/src/thumbnail.rs index 543e8ac..a636458 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -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, decoder: Option>>, image: Option, - // ponytail: one completed thumbnail plus an in-flight replacement, not a video cache. - cached: Option, + // ponytail: eight-entry linear lookup; use a keyed LRU if the buffer grows. + cached: std::collections::VecDeque, error: Option, } @@ -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}; diff --git a/src/youtube.rs b/src/youtube.rs index dac2898..8af54e3 100644 --- a/src/youtube.rs +++ b/src/youtube.rs @@ -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, - // Search descriptions are truncated; only full video metadata populates this cache. - #[serde(skip)] - pub description: Option, } #[derive(Deserialize)]