feat: retain bounded preview caches across selections and views
This commit is contained in:
+107
-33
@@ -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!(
|
||||
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
@@ -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,8 +91,16 @@ 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 {
|
||||
@@ -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};
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user