feat: retain bounded preview caches across selections and views
This commit is contained in:
+110
-36
@@ -21,7 +21,11 @@
|
|||||||
//! Left-aligned text starts one column after the actual image width, including height-limited images.
|
//! 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.
|
//! 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.
|
//! 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.
|
//! 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`.
|
//! 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.
|
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
|
||||||
@@ -92,6 +96,18 @@ struct Options {
|
|||||||
query: Option<String>,
|
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)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
enum Mode {
|
enum Mode {
|
||||||
Insert,
|
Insert,
|
||||||
@@ -150,7 +166,7 @@ impl View {
|
|||||||
videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
|
||||||
}
|
}
|
||||||
if !videos.is_empty() {
|
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 keep: Vec<_> = {
|
||||||
let mut ids = HashSet::with_capacity(self.videos.len() + videos.len());
|
let mut ids = HashSet::with_capacity(self.videos.len() + videos.len());
|
||||||
ids.extend(self.videos.iter().map(|video| video.id.as_str()));
|
ids.extend(self.videos.iter().map(|video| video.id.as_str()));
|
||||||
@@ -221,6 +237,8 @@ struct App {
|
|||||||
description_open: bool,
|
description_open: bool,
|
||||||
description_job: Option<(usize, Job)>,
|
description_job: Option<(usize, Job)>,
|
||||||
description_error: Option<String>,
|
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_scroll: u16,
|
||||||
description_max_scroll: u16,
|
description_max_scroll: u16,
|
||||||
description_viewport: Option<(u16, u16)>,
|
description_viewport: Option<(u16, u16)>,
|
||||||
@@ -251,6 +269,7 @@ impl App {
|
|||||||
description_open: false,
|
description_open: false,
|
||||||
description_job: None,
|
description_job: None,
|
||||||
description_error: None,
|
description_error: None,
|
||||||
|
descriptions: std::collections::VecDeque::with_capacity(DESCRIPTION_CACHE_SIZE),
|
||||||
description_scroll: 0,
|
description_scroll: 0,
|
||||||
description_max_scroll: 0,
|
description_max_scroll: 0,
|
||||||
description_viewport: None,
|
description_viewport: None,
|
||||||
@@ -458,6 +477,8 @@ impl App {
|
|||||||
self.close_thumbnail();
|
self.close_thumbnail();
|
||||||
match config::load(&self.config_path) {
|
match config::load(&self.config_path) {
|
||||||
Ok(config) => {
|
Ok(config) => {
|
||||||
|
self.thumbnail.clear_cache();
|
||||||
|
self.descriptions.clear();
|
||||||
self.apply_config(config);
|
self.apply_config(config);
|
||||||
self.load_view();
|
self.load_view();
|
||||||
}
|
}
|
||||||
@@ -644,7 +665,12 @@ impl App {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let video = &view.videos[index];
|
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;
|
return;
|
||||||
}
|
}
|
||||||
match Job::description(&video.id) {
|
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) {
|
fn play(&mut self) {
|
||||||
let view = &self.views[self.view_index];
|
let view = &self.views[self.view_index];
|
||||||
let Some(video) = view
|
let Some(video) = view
|
||||||
@@ -689,9 +729,9 @@ impl App {
|
|||||||
.description_job
|
.description_job
|
||||||
.take()
|
.take()
|
||||||
.expect("completed description job exists");
|
.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)) {
|
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),
|
Err(error) => self.description_error = Some(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -974,7 +1014,7 @@ impl App {
|
|||||||
{
|
{
|
||||||
let popup = preview_area(frame.area());
|
let popup = preview_area(frame.area());
|
||||||
frame.render_widget(Clear, popup);
|
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("") => "This video has no description.",
|
||||||
Some(description) => description,
|
Some(description) => description,
|
||||||
None => self
|
None => self
|
||||||
@@ -1065,7 +1105,8 @@ impl App {
|
|||||||
excerpt.height -= height + gap;
|
excerpt.height -= height + gap;
|
||||||
details.height = height;
|
details.height = height;
|
||||||
frame.render_widget(metadata, details);
|
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("") => "This video has no description.",
|
||||||
Some(description) => description,
|
Some(description) => description,
|
||||||
None => self
|
None => self
|
||||||
@@ -1074,10 +1115,7 @@ impl App {
|
|||||||
.unwrap_or("Loading full description..."),
|
.unwrap_or("Loading full description..."),
|
||||||
};
|
};
|
||||||
let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim();
|
let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim();
|
||||||
let overflow = video
|
let overflow = cached.is_some_and(|text| !text.is_empty())
|
||||||
.description
|
|
||||||
.as_deref()
|
|
||||||
.is_some_and(|text| !text.is_empty())
|
|
||||||
&& !excerpt.is_empty()
|
&& !excerpt.is_empty()
|
||||||
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
&& paragraph.line_count(excerpt.width) > usize::from(excerpt.height);
|
||||||
frame.render_widget(paragraph, excerpt);
|
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]
|
#[test]
|
||||||
fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() {
|
fn thumbnail_ellipsis_marks_only_hidden_content_and_preserves_wide_characters() {
|
||||||
use ratatui::{Terminal, backend::TestBackend};
|
use ratatui::{Terminal, backend::TestBackend};
|
||||||
@@ -1329,11 +1407,14 @@ mod tests {
|
|||||||
("a界界界界界界界界界", true, Some(63)),
|
("a界界界界界界界界界", true, Some(63)),
|
||||||
("", true, Some(46)),
|
("", true, Some(46)),
|
||||||
] {
|
] {
|
||||||
app.views[0].videos[0].description = Some(format!(
|
app.remember_description(
|
||||||
"{}{last_line}{}",
|
"jNQXAC9IVRw".into(),
|
||||||
"line\n".repeat(6),
|
format!(
|
||||||
if hidden { "\nhidden content" } else { "" },
|
"{}{last_line}{}",
|
||||||
));
|
"line\n".repeat(6),
|
||||||
|
if hidden { "\nhidden content" } else { "" },
|
||||||
|
),
|
||||||
|
);
|
||||||
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| app.draw(frame, Duration::ZERO))
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
||||||
@@ -1369,8 +1450,10 @@ mod tests {
|
|||||||
app.views[0].videos = youtube::search_results(
|
app.views[0].videos = youtube::search_results(
|
||||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#,
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First second third fourth fifth","channel":"Creator","upload_date":"20050423"}]}"#,
|
||||||
).unwrap().videos;
|
).unwrap().videos;
|
||||||
app.views[0].videos[0].description =
|
app.remember_description(
|
||||||
Some(format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)));
|
"jNQXAC9IVRw".into(),
|
||||||
|
format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)),
|
||||||
|
);
|
||||||
app.views[0].selection.select(Some(0));
|
app.views[0].selection.select(Some(0));
|
||||||
app.thumbnail_open = true;
|
app.thumbnail_open = true;
|
||||||
for (width, height) in [(80, 24), (120, 40), (20, 10)] {
|
for (width, height) in [(80, 24), (120, 40), (20, 10)] {
|
||||||
@@ -1464,7 +1547,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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 view = View::new("Search".into());
|
||||||
let mut first = youtube::search_results(
|
let mut first = youtube::search_results(
|
||||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#,
|
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;
|
first.has_more = true;
|
||||||
view.accept_page(first, false).unwrap();
|
view.accept_page(first, false).unwrap();
|
||||||
view.selection.select(Some(1));
|
view.selection.select(Some(1));
|
||||||
view.videos[1].description = Some("Cached full description.".into());
|
|
||||||
let mut next = youtube::search_results(
|
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}]}"#,
|
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"]
|
["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE", "aaaaaaaaaaa"]
|
||||||
);
|
);
|
||||||
assert_eq!(view.videos[1].title, "Second");
|
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.selection.selected(), Some(1));
|
||||||
assert_eq!(view.next_start, 41);
|
assert_eq!(view.next_start, 41);
|
||||||
assert!(view.has_more);
|
assert!(view.has_more);
|
||||||
@@ -1517,17 +1595,12 @@ mod tests {
|
|||||||
assert_eq!(view.videos.last().unwrap().timestamp, Some(200));
|
assert_eq!(view.videos.last().unwrap().timestamp, Some(200));
|
||||||
assert_eq!(view.pending.len(), 2);
|
assert_eq!(view.pending.len(), 2);
|
||||||
view.selection.select(Some(PAGE_SIZE - 1));
|
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();
|
let second = youtube::feed_results(&format!("{}\n{short}", long_page(180))).unwrap();
|
||||||
view.accept_page(second, true).unwrap();
|
view.accept_page(second, true).unwrap();
|
||||||
assert_eq!(view.videos.len(), PAGE_SIZE * 2);
|
assert_eq!(view.videos.len(), PAGE_SIZE * 2);
|
||||||
assert_eq!(view.videos.last().unwrap().timestamp, Some(180));
|
assert_eq!(view.videos.last().unwrap().timestamp, Some(180));
|
||||||
assert_eq!(view.pending.len(), 2);
|
assert_eq!(view.pending.len(), 2);
|
||||||
assert_eq!(view.selection.selected(), Some(PAGE_SIZE - 1));
|
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(
|
view.accept_page(
|
||||||
youtube::feed_results(
|
youtube::feed_results(
|
||||||
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Long last row","timestamp":179}]} {"entries":[]}"#,
|
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()));
|
app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string()));
|
||||||
assert_eq!(app.views[0].next_start, 21);
|
assert_eq!(app.views[0].next_start, 21);
|
||||||
app.views[0].selection.select(Some(17));
|
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(1);
|
||||||
app.switch_view(0);
|
app.switch_view(0);
|
||||||
assert_eq!(app.views[0].next_start, 21);
|
assert_eq!(app.views[0].next_start, 21);
|
||||||
@@ -1657,7 +1730,7 @@ mod tests {
|
|||||||
assert!(app.job.is_none());
|
assert!(app.job.is_none());
|
||||||
assert_eq!(app.views[0].videos.len(), PAGE_SIZE);
|
assert_eq!(app.views[0].videos.len(), PAGE_SIZE);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.views[0].videos[17].description.as_deref(),
|
cached_description(&app.descriptions, &app.views[0].videos[17].id),
|
||||||
Some("Cached.")
|
Some("Cached.")
|
||||||
);
|
);
|
||||||
assert_eq!(app.views[0].next_start, 21);
|
assert_eq!(app.views[0].next_start, 21);
|
||||||
@@ -1876,7 +1949,6 @@ mod tests {
|
|||||||
uploader_id: None,
|
uploader_id: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
publication_date: None,
|
publication_date: None,
|
||||||
description: None,
|
|
||||||
},
|
},
|
||||||
Video {
|
Video {
|
||||||
id: "rQ_J9WH6CGk".into(),
|
id: "rQ_J9WH6CGk".into(),
|
||||||
@@ -1886,7 +1958,6 @@ mod tests {
|
|||||||
uploader_id: None,
|
uploader_id: None,
|
||||||
timestamp: None,
|
timestamp: None,
|
||||||
publication_date: None,
|
publication_date: None,
|
||||||
description: None,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
app.views[0].selection.select(Some(0));
|
app.views[0].selection.select(Some(0));
|
||||||
@@ -2045,7 +2116,10 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
.videos;
|
.videos;
|
||||||
app.views[1].page_error = Some("Retained page error".into());
|
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].videos = youtube::feed_results(videos).unwrap().videos;
|
||||||
app.views[3].selection.select(Some(0));
|
app.views[3].selection.select(Some(0));
|
||||||
app.views[3].loaded = true;
|
app.views[3].loaded = true;
|
||||||
@@ -2080,7 +2154,7 @@ mod tests {
|
|||||||
Some("Retained page error")
|
Some("Retained page error")
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
app.views[1].videos[1].description.as_deref(),
|
cached_description(&app.descriptions, &app.views[1].videos[1].id),
|
||||||
Some("Retained description")
|
Some("Retained description")
|
||||||
);
|
);
|
||||||
assert!(!app.views[4].loaded);
|
assert!(!app.views[4].loaded);
|
||||||
@@ -2358,7 +2432,7 @@ mod tests {
|
|||||||
app.views[0].videos = youtube::search_results(
|
app.views[0].videos = youtube::search_results(
|
||||||
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#,
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#,
|
||||||
).unwrap().videos;
|
).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].selection.select(Some(0));
|
||||||
app.views[0].loaded = true;
|
app.views[0].loaded = true;
|
||||||
app.thumbnail_open = true;
|
app.thumbnail_open = true;
|
||||||
|
|||||||
+78
-7
@@ -18,6 +18,8 @@ use ratatui_image::{
|
|||||||
|
|
||||||
use crate::youtube::ThumbnailJob;
|
use crate::youtube::ThumbnailJob;
|
||||||
|
|
||||||
|
const CACHE_SIZE: usize = 8;
|
||||||
|
|
||||||
struct ThumbnailImage {
|
struct ThumbnailImage {
|
||||||
id: String,
|
id: String,
|
||||||
protocol: ThreadProtocol,
|
protocol: ThreadProtocol,
|
||||||
@@ -31,8 +33,8 @@ pub struct Thumbnail {
|
|||||||
fetch: Option<ThumbnailJob>,
|
fetch: Option<ThumbnailJob>,
|
||||||
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
decoder: Option<Receiver<Result<ThumbnailImage, String>>>,
|
||||||
image: Option<ThumbnailImage>,
|
image: Option<ThumbnailImage>,
|
||||||
// ponytail: one completed thumbnail plus an in-flight replacement, not a video cache.
|
// ponytail: eight-entry linear lookup; use a keyed LRU if the buffer grows.
|
||||||
cached: Option<ThumbnailImage>,
|
cached: std::collections::VecDeque<ThumbnailImage>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +46,7 @@ impl Thumbnail {
|
|||||||
fetch: None,
|
fetch: None,
|
||||||
decoder: None,
|
decoder: None,
|
||||||
image: None,
|
image: None,
|
||||||
cached: None,
|
cached: std::collections::VecDeque::with_capacity(CACHE_SIZE),
|
||||||
error: None,
|
error: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -62,10 +64,16 @@ impl Thumbnail {
|
|||||||
if self.selected.as_deref() == Some(id) {
|
if self.selected.as_deref() == Some(id) {
|
||||||
return;
|
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.cancel();
|
||||||
self.selected = Some(id.to_owned());
|
self.selected = Some(id.to_owned());
|
||||||
if self.cached.as_ref().is_some_and(|image| image.id == id) {
|
if let Some(image) = cached {
|
||||||
self.image = self.cached.take();
|
self.image = Some(image);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
match ThumbnailJob::start(id) {
|
match ThumbnailJob::start(id) {
|
||||||
@@ -83,10 +91,18 @@ impl Thumbnail {
|
|||||||
if let Some(image) = self.image.take()
|
if let Some(image) = self.image.take()
|
||||||
&& image.complete
|
&& 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 {
|
pub fn is_loading(&self) -> bool {
|
||||||
self.selected.is_some()
|
self.selected.is_some()
|
||||||
&& self.error.is_none()
|
&& self.error.is_none()
|
||||||
@@ -180,7 +196,6 @@ impl Thumbnail {
|
|||||||
// area again before rendering, so rapid resizes cannot draw an old size.
|
// area again before rendering, so rapid resizes cannot draw an old size.
|
||||||
if image.protocol.update_resized_protocol(response) {
|
if image.protocol.update_resized_protocol(response) {
|
||||||
image.complete = true;
|
image.complete = true;
|
||||||
self.cached = None;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(Err(error)) => {
|
Ok(Err(error)) => {
|
||||||
@@ -281,6 +296,62 @@ mod tests {
|
|||||||
assert!(thumbnail.error.is_none());
|
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]
|
#[test]
|
||||||
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
|
fn small_thumbnail_fills_popup_proportionally_and_resizes() {
|
||||||
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
use ratatui::{Terminal, backend::TestBackend, style::Color};
|
||||||
|
|||||||
@@ -27,9 +27,6 @@ pub struct Video {
|
|||||||
/// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date.
|
/// Cached YYYY-MM-DD label; a leading ~ marks an estimated UTC date.
|
||||||
#[serde(default, rename = "upload_date")]
|
#[serde(default, rename = "upload_date")]
|
||||||
pub publication_date: Option<String>,
|
pub publication_date: Option<String>,
|
||||||
// Search descriptions are truncated; only full video metadata populates this cache.
|
|
||||||
#[serde(skip)]
|
|
||||||
pub description: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user