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;