From 01f51be18e42555db517d11f8d491e05623182c7 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 11:23:25 +0200 Subject: [PATCH] feat: improve thumbnail readability and add description excerpts --- src/main.rs | 89 ++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 65 insertions(+), 24 deletions(-) diff --git a/src/main.rs b/src/main.rs index 334dbb2..477340b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,9 @@ //! Normal-mode t toggles a thumbnail in the same-sized popup; j/k follows selection, Esc closes. //! Images use native terminal graphics when supported, otherwise colored half-block characters. //! Thumbnails scale proportionally in the left pane without cropping. -//! The right pane shows a wrapped title above author · publication date, dimmed like the footer. +//! The right pane has a bold title above a dimmed author · publication date and description excerpt. +//! Text wraps two columns before the right border; the excerpt is clipped to the remaining height. +//! Both previews share asynchronous full-description fetching and per-video caching. //! 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. @@ -34,7 +36,7 @@ //! Search pages contain 20 results; Feed/tag pages request 20 uploads per channel's /videos tab. //! In normal mode, approaching the loaded end fetches the next page without replacing rows/selection. //! Loading result/preview panes animate a spinner after their title, including additional pages. -//! Thumbnail spinners cover download, decode, and resize work; ready content and errors stop them. +//! Thumbnail spinners cover image download/decode/resize and description fetching; errors stop them. //! Feed rows use approximate upload dates; older rows wait until newer channel pages catch up. //! Undated feed rows appear at exhaustion. Cached views retain their page position across switches. //! Switching views cancels in-flight extraction; returning resumes the same pending range. @@ -822,9 +824,9 @@ impl App { view.selection .select(Some(index.min(view.videos.len() - 1))); } - if !self.description_open { + if !self.description_open && !self.thumbnail_open { self.description_job = None; - } else if !description_was_open + } else if (!description_was_open && !thumbnail_was_open) || self.views[self.view_index].selection.selected() != selected_before { self.load_description(); @@ -1029,8 +1031,9 @@ impl App { Constraint::Fill(2), ]) .areas(block.inner(popup)); + details.width = details.width.saturating_sub(2); let metadata = Paragraph::new(vec![ - Line::raw(video.title.as_str()), + Line::raw(video.title.as_str()).bold(), Line::from(vec![ video.channel.as_deref().unwrap_or("Unknown").into(), " · ".into(), @@ -1039,10 +1042,10 @@ impl App { .as_deref() .unwrap_or("Unknown") .into(), - ]), + ]) + .dim(), ]) - .wrap(Wrap { trim: true }) - .dim(); + .wrap(Wrap { trim: true }); let height = if details.is_empty() { 0 } else { @@ -1050,16 +1053,32 @@ impl App { .line_count(details.width) .min(details.height as usize) as u16 }; - details.y += (details.height - height) / 2; + let mut excerpt = details; + let gap = (excerpt.height - height).min(1); + excerpt.y += height + gap; + excerpt.height -= height + gap; details.height = height; frame.render_widget(metadata, details); + let description = match video.description.as_deref() { + Some("") => "This video has no description.", + Some(description) => description, + None => self + .description_error + .as_deref() + .unwrap_or("Loading full description..."), + }; + frame.render_widget( + Paragraph::new(description).wrap(Wrap { trim: false }).dim(), + excerpt, + ); // Drawing polls completion and starts resize work before we choose the title state. self.thumbnail.draw(frame, image); frame.render_widget( block.title(loading_title( " Thumbnail ", "· t/Esc close ", - self.thumbnail.is_loading().then_some(spinner), + (self.thumbnail.is_loading() || self.description_job.is_some()) + .then_some(spinner), )), popup, ); @@ -1260,35 +1279,57 @@ mod tests { } #[test] - fn thumbnail_title_wraps_above_a_dimmed_byline_in_the_right_pane() { + fn thumbnail_text_has_contrast_a_right_gutter_and_a_clipped_excerpt() { use ratatui::{Terminal, backend::TestBackend, style::Modifier}; let mut app = app(&["tubular"]); 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.views[0].selection.select(Some(0)); app.thumbnail_open = true; - for (width, height, x, title_y, title_lines) in [ - (80, 24, 46, 10, vec!["First second third", "fourth fifth"]), - (120, 40, 69, 19, vec!["First second third fourth fifth"]), - ] { + for (width, height) in [(80, 24), (120, 40), (20, 10)] { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal .draw(|frame| app.draw(frame, Duration::ZERO)) .unwrap(); let buffer = terminal.backend().buffer(); - let mut lines = title_lines; - lines.push("Creator · 2005-04-23"); - for (row, text) in lines.iter().enumerate() { - for (column, character) in text.chars().enumerate() { - let cell = buffer - .cell((x + column as u16, title_y + row as u16)) - .unwrap(); - assert_eq!(cell.symbol(), character.to_string()); - assert!(cell.modifier.contains(Modifier::DIM)); + let popup = preview_area(Rect::new(0, 0, width, height)); + let mut excerpt_rows = Vec::new(); + for y in popup.y + 1..popup.bottom() - 1 { + for x in popup.right() - 3..popup.right() - 1 { + assert_eq!(buffer.cell((x, y)).unwrap().symbol(), " "); + } + let row: String = (popup.x + popup.width / 2..popup.right() - 3) + .map(|x| buffer.cell((x, y)).unwrap().symbol()) + .collect(); + assert!(!row.contains("CLIPPED SENTINEL")); + if row.contains("Excerpt line") { + excerpt_rows.push(y); } } + if width == 20 { + assert!(excerpt_rows.is_empty()); // Heading takes all available height. + continue; + } + let title = buffer + .cell((if width == 80 { 46 } else { 69 }, popup.y + 1)) + .unwrap(); + assert_eq!(title.symbol(), "F"); + assert!(title.modifier.contains(Modifier::BOLD)); + assert!(!title.modifier.contains(Modifier::DIM)); + let excerpt_y = *excerpt_rows + .last() + .expect("description fills remaining space"); + assert_eq!(excerpt_y, popup.bottom() - 2); + let excerpt = buffer + .cell((if width == 80 { 46 } else { 69 }, excerpt_y)) + .unwrap(); + assert_eq!(excerpt.symbol(), "E"); + assert!(excerpt.modifier.contains(Modifier::DIM)); + assert!(!excerpt.modifier.contains(Modifier::BOLD)); } }