From b0c8fa18c1db314f36ad6c2a465a218993d0886c Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 14:58:49 +0200 Subject: [PATCH] fix: stabilize and unify video info previews --- src/main.rs | 479 ++++++++++++++++++++++++----------------------- src/thumbnail.rs | 138 ++++++++++++-- 2 files changed, 369 insertions(+), 248 deletions(-) diff --git a/src/main.rs b/src/main.rs index 21ac111..f43d438 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,16 +12,17 @@ //! View tabs wrap whole labels into rows using terminal-cell widths and reflow on resize. //! Short terminals keep the selected tab row visible while reserving space for the other panes. //! A single label wider than the terminal is clipped rather than split across rows. -//! Normal-mode d opens the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages. -//! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells. -//! Esc closes the popup; j/k loads the selected video's description and i returns to editing. -//! Normal-mode t toggles a thumbnail in the same-sized popup; j/k follows selection, Esc closes. +//! Normal-mode d opens video info; d expands the full description in place, then returns to info. +//! Both modes share one centered popup, up to 70% width and 60% height, capped at 96 by 20 cells. +//! Full descriptions scroll with Up/Down and PgUp/PgDn; j/k selects videos in either mode. +//! Esc closes the popup; i returns to editing. Mode toggles retain cached content and pending work. //! Images use native terminal graphics when supported, otherwise colored half-block characters. -//! Thumbnails scale proportionally and align to the popup's left inner edge. -//! Left-aligned text starts one column after the actual image width, including height-limited images. +//! HQ thumbnails reserve their proportional 4:3 footprint before loading, avoiding metadata reflow. +//! Thumbnails align to the popup's left inner edge, with two columns before the left-aligned text. //! 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 an in-memory LRU of 64 full descriptions, keyed by video ID across views. +//! Description excerpts use normal brightness and retain a two-column right gutter. +//! Overflowing excerpts end with … without splitting wide glyphs; full descriptions preserve paragraphs. +//! Both modes 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. @@ -57,7 +58,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 image download/decode/resize and description fetching; errors stop them. +//! Info 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. @@ -101,7 +102,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube and open selected videos in Terebi", - after_help = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --new-instance --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category filter, and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d toggles description; t toggles thumbnail; c browses saved categories; s edits channel tags; S edits all channels in the current category; q quits.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter browses; p immediately saves pin/unpin; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses the selected match; Esc returns to NORMAL with the filter retained. In NORMAL, j/k and pinning operate on the filtered list. No matches makes Enter/p a no-op. Reopening the selector clears the filter.\nHidden category views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\npinned_categories is a top-level array of category names; pins referencing removed categories are ignored.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, s, S, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" + after_help = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --new-instance --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category filter, and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d opens video info (again for full description); c browses saved categories; s edits channel tags; S edits all channels in the current category; q quits.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter browses; p immediately saves pin/unpin; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses the selected match; Esc returns to NORMAL with the filter retained. In NORMAL, j/k and pinning operate on the filtered list. No matches makes Enter/p a no-op. Reopening the selector clears the filter.\nHidden category views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\npinned_categories is a top-level array of category names; pins referencing removed categories are ignored.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions preserve paragraphs; Up/Down scroll lines and PgUp/PgDn scroll pages. j/k selects videos in either mode.\nThumbnails stay left-aligned with two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable; accepts --new-instance --url followed by a YouTube video URL. @@ -275,6 +276,12 @@ impl CategoryBrowser { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum PreviewMode { + Info, + Description, +} + struct App { options: Options, query: String, @@ -288,9 +295,8 @@ struct App { category_dialog: Option, category_browser: Option, temporary_category: Option, - thumbnail_open: bool, + preview: Option, thumbnail: thumbnail::Thumbnail, - description_open: bool, description_job: Option<(usize, Job)>, description_error: Option, // ponytail: 64-entry linear lookup; use a keyed LRU if the buffer grows. @@ -322,9 +328,8 @@ impl App { category_dialog: None, category_browser: None, temporary_category: None, - thumbnail_open: false, + preview: None, thumbnail: thumbnail::Thumbnail::new(), - description_open: false, description_job: None, description_error: None, descriptions: std::collections::VecDeque::with_capacity(DESCRIPTION_CACHE_SIZE), @@ -370,9 +375,7 @@ impl App { } fn open_category_browser(&mut self) { - self.close_thumbnail(); - self.description_open = false; - self.description_job = None; + self.close_preview(); let mut selection = ListState::default(); selection.select( self.config @@ -539,10 +542,7 @@ impl App { self.job = None; self.view_index = index; } - self.close_thumbnail(); - self.description_open = false; - self.description_job = None; - self.description_viewport = None; + self.close_preview(); if !self.views[index].loaded && self.views[index].page_error.is_none() { if self.job.is_none() { self.load_page(); @@ -554,9 +554,7 @@ impl App { fn load_view(&mut self) { self.job = None; - self.description_open = false; - self.description_job = None; - self.close_thumbnail(); + self.close_preview(); self.views[self.view_index].reset(); self.load_page(); } @@ -667,8 +665,7 @@ impl App { && self.mode == Mode::Normal && self.category_dialog.is_none() && self.category_browser.is_none() - && !self.description_open - && !self.thumbnail_open + && self.preview.is_none() && self.views[self.view_index].needs_page() } @@ -727,13 +724,11 @@ impl App { self.temporary_category = Some(self.views[self.view_index].name.clone()); } self.job = None; - self.description_open = false; - self.description_job = None; - self.close_thumbnail(); + self.close_preview(); } fn refresh(&mut self) { - self.close_thumbnail(); + self.close_preview(); match config::load(&self.config_path) { Ok(config) => { self.thumbnail.clear_cache(); @@ -746,7 +741,7 @@ impl App { } fn open_category_picker(&mut self) { - self.close_thumbnail(); + self.close_preview(); let view = &self.views[self.view_index]; let Some(video) = view .selection @@ -795,8 +790,6 @@ impl App { }) .collect(); selection.select((!self.config.categories.is_empty()).then_some(0)); - self.description_open = false; - self.description_job = None; self.category_dialog = Some(CategoryDialog { target: CategoryTarget::Channel { handle, channel }, label, @@ -839,9 +832,7 @@ impl App { .collect(); let mut selection = ListState::default(); selection.select(self.config.categories.keys().position(|tag| tag == &name)); - self.close_thumbnail(); - self.description_open = false; - self.description_job = None; + self.close_preview(); self.category_dialog = Some(CategoryDialog { label: format!(" {name} · {} channels ", channels.len()), target: CategoryTarget::Category { name, channels }, @@ -961,17 +952,19 @@ impl App { self.category_dialog = Some(dialog); } - fn close_thumbnail(&mut self) { - if self.thumbnail_open { - self.thumbnail_open = false; + fn close_preview(&mut self) { + if self.preview.take().is_some() { self.thumbnail.cancel(); } + self.description_job = None; + self.description_viewport = None; } fn load_description(&mut self) { self.description_job = None; self.description_error = None; self.description_scroll = 0; + self.description_max_scroll = 0; self.description_viewport = None; let view = &self.views[self.view_index]; let Some(index) = view @@ -1079,8 +1072,7 @@ impl App { return false; } let selected_before = self.views[self.view_index].selection.selected(); - let description_was_open = self.description_open; - let thumbnail_was_open = self.thumbnail_open; + let preview_was_open = self.preview.is_some(); if self.mode == Mode::Insert { match key.code { KeyCode::Esc => self.mode = Mode::Normal, @@ -1102,43 +1094,31 @@ impl App { match key.code { KeyCode::Char('q') => return true, KeyCode::Char('i') => { - self.description_open = false; - self.close_thumbnail(); + self.close_preview(); self.mode = Mode::Insert; } KeyCode::Char('d') => { - self.close_thumbnail(); - self.description_open = !self.description_open - && self.views[self.view_index] + self.preview = match self.preview { + Some(PreviewMode::Info) => Some(PreviewMode::Description), + Some(PreviewMode::Description) => Some(PreviewMode::Info), + None => self.views[self.view_index] .selection .selected() - .is_some_and(|index| index < self.views[self.view_index].videos.len()); + .filter(|&index| index < self.views[self.view_index].videos.len()) + .map(|_| PreviewMode::Info), + }; } - KeyCode::Char('t') => { - if self.thumbnail_open { - self.close_thumbnail(); - } else { - self.thumbnail_open = self.views[self.view_index] - .selection - .selected() - .is_some_and(|index| index < self.views[self.view_index].videos.len()); - } - self.description_open = false; - } - KeyCode::Esc => { - self.description_open = false; - self.close_thumbnail(); - } - KeyCode::Down if self.description_open => { + KeyCode::Esc => self.close_preview(), + KeyCode::Down if self.preview == Some(PreviewMode::Description) => { self.description_scroll = self .description_scroll .saturating_add(1) .min(self.description_max_scroll); } - KeyCode::Up if self.description_open => { + KeyCode::Up if self.preview == Some(PreviewMode::Description) => { self.description_scroll = self.description_scroll.saturating_sub(1); } - KeyCode::PageDown if self.description_open => { + KeyCode::PageDown if self.preview == Some(PreviewMode::Description) => { let page = self .description_viewport .map(|(_, height)| height) @@ -1149,7 +1129,7 @@ impl App { .saturating_add(page) .min(self.description_max_scroll); } - KeyCode::PageUp if self.description_open => { + KeyCode::PageUp if self.preview == Some(PreviewMode::Description) => { let page = self .description_viewport .map(|(_, height)| height) @@ -1186,15 +1166,15 @@ impl App { view.selection .select(Some(index.min(view.videos.len() - 1))); } - if !self.description_open && !self.thumbnail_open { + if self.preview.is_none() { self.description_job = None; - } else if (!description_was_open && !thumbnail_was_open) + } else if !preview_was_open || self.views[self.view_index].selection.selected() != selected_before { self.load_description(); } - if self.thumbnail_open - && (!thumbnail_was_open + if self.preview.is_some() + && (!preview_was_open || self.views[self.view_index].selection.selected() != selected_before) && let Some(video) = self.views[self.view_index] .selection @@ -1332,69 +1312,18 @@ impl App { Mode::Normal if self.category_browser.is_some() => { "CATEGORIES NORMAL · j/k · i filter · Enter browse · p pin/unpin · Esc close" } - Mode::Normal if self.description_open => { - "NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit" + Mode::Normal if self.preview == Some(PreviewMode::Description) => { + "NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d info · Esc close · i insert · q quit" } - Mode::Normal if self.thumbnail_open => { - "NORMAL · j/k video · t/Esc close · d description · i insert · q quit" + Mode::Normal if self.preview == Some(PreviewMode::Info) => { + "NORMAL · j/k video · d description · Esc close · i insert · q quit" } Mode::Normal => { - "NORMAL · j/k · Enter play · i search · c browse · s channel tags · S bulk tags · d info · t image · Tab views · r refresh · q quit" + "NORMAL · j/k · Enter play · i search · c browse · s channel tags · S bulk tags · d info · Tab views · r refresh · q quit" } }; frame.render_widget(Paragraph::new(help_text).dim(), help); - if self.description_open - && let Some(video) = self.views[self.view_index] - .selection - .selected() - .and_then(|index| self.views[self.view_index].videos.get(index)) - { - let popup = preview_area(frame.area()); - frame.render_widget(Clear, popup); - let text = match cached_description(&self.descriptions, &video.id) { - Some("") => "This video has no description.", - Some(description) => description, - None => self - .description_error - .as_deref() - .unwrap_or("Loading full description..."), - }; - let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); - let viewport = ( - popup.width.saturating_sub(2), - popup.height.saturating_sub(2), - ); - if self.description_viewport != Some(viewport) { - self.description_max_scroll = if viewport.0 == 0 || viewport.1 == 0 { - 0 - } else { - paragraph - .line_count(viewport.0) - .saturating_sub(viewport.1 as usize) - .min(u16::MAX as usize) as u16 - }; - self.description_viewport = Some(viewport); - } - self.description_scroll = self.description_scroll.min(self.description_max_scroll); - frame.render_widget( - paragraph.scroll((self.description_scroll, 0)).block( - Block::bordered() - .title(loading_title( - " Description ", - "· ↑/↓ PgUp/PgDn · d/Esc close ", - self.description_job.is_some().then_some(spinner), - )) - .title_bottom(Line::from(vec![ - " ".into(), - video.title.as_str().into(), - " ".into(), - ])) - .border_style(Style::new().fg(Color::Cyan)), - ), - popup, - ); - } - if self.thumbnail_open + if let Some(mode) = self.preview && let Some(video) = self.views[self.view_index] .selection .selected() @@ -1403,47 +1332,18 @@ impl App { let popup = preview_area(frame.area()); frame.render_widget(Clear, popup); let block = Block::bordered().border_style(Style::new().fg(Color::Cyan)); - let [image, _, mut details] = Layout::horizontal([ - Constraint::Fill(3), - Constraint::Length(1), - Constraint::Fill(2), - ]) - .areas(block.inner(popup)); - let image_width = self.thumbnail.draw(frame, image); - let reclaimed = image.width - image_width; - details.x -= reclaimed; - details.width += reclaimed; - details.width = details.width.saturating_sub(2); - let metadata = Paragraph::new(vec![ - Line::raw(video.title.as_str()).bold(), - Line::default(), - Line::from(vec![ - video.channel.as_deref().unwrap_or("Unknown").into(), - " · ".into(), - video - .publication_date - .as_deref() - .unwrap_or("Unknown") - .into(), - ]) - .dim(), - ]) - .wrap(Wrap { trim: true }); - let height = if details.is_empty() { - 0 + let block = if mode == PreviewMode::Description { + block.title_bottom(Line::from(vec![ + " ".into(), + video.title.as_str().into(), + " ".into(), + ])) } else { - metadata - .line_count(details.width) - .min(details.height as usize) as u16 + block }; - 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 inner = block.inner(popup); let cached = cached_description(&self.descriptions, &video.id); - let description = match cached { + let text = match cached { Some("") => "This video has no description.", Some(description) => description, None => self @@ -1451,43 +1351,110 @@ impl App { .as_deref() .unwrap_or("Loading full description..."), }; - let paragraph = Paragraph::new(description).wrap(Wrap { trim: false }).dim(); - 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); - if overflow { - let buffer = frame.buffer_mut(); - let y = excerpt.bottom() - 1; - let end = (excerpt.x..excerpt.right()) - .rev() - .find_map(|x| { - let symbol = buffer - .cell((x, y)) - .expect("excerpt is inside frame") - .symbol(); - (!symbol.trim().is_empty()) - .then(|| x.saturating_add(Span::raw(symbol).width() as u16)) - }) - .unwrap_or(excerpt.x); - let mut x = end.min(excerpt.right() - 1); - // Do not leave half of a wide character beneath the ellipsis. - if x > excerpt.x && Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1 - { - x -= 1; + if mode == PreviewMode::Description { + let paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + let viewport = (inner.width, inner.height); + if self.description_viewport != Some(viewport) { + self.description_max_scroll = if viewport.0 == 0 || viewport.1 == 0 { + 0 + } else { + paragraph + .line_count(viewport.0) + .saturating_sub(viewport.1 as usize) + .min(u16::MAX as usize) as u16 + }; + self.description_viewport = Some(viewport); + } + self.description_scroll = self.description_scroll.min(self.description_max_scroll); + frame.render_widget(paragraph.scroll((self.description_scroll, 0)), inner); + } else { + let [image, _, mut details] = Layout::horizontal([ + Constraint::Fill(3), + Constraint::Length(2), + Constraint::Fill(2), + ]) + .areas(inner); + let image_width = self.thumbnail.draw(frame, image); + let reclaimed = image.width - image_width; + details.x -= reclaimed; + details.width += reclaimed; + details.width = details.width.saturating_sub(2); + let metadata = Paragraph::new(vec![ + Line::raw(video.title.as_str()).bold(), + Line::default(), + Line::from(vec![ + video.channel.as_deref().unwrap_or("Unknown").into(), + " · ".into(), + video + .publication_date + .as_deref() + .unwrap_or("Unknown") + .into(), + ]) + .dim(), + ]) + .wrap(Wrap { trim: true }); + let height = if details.is_empty() { + 0 + } else { + metadata + .line_count(details.width) + .min(details.height as usize) as u16 + }; + 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 paragraph = Paragraph::new(text).wrap(Wrap { trim: false }); + 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); + if overflow { + let buffer = frame.buffer_mut(); + let y = excerpt.bottom() - 1; + let end = (excerpt.x..excerpt.right()) + .rev() + .find_map(|x| { + let symbol = buffer + .cell((x, y)) + .expect("excerpt is inside frame") + .symbol(); + (!symbol.trim().is_empty()) + .then(|| x.saturating_add(Span::raw(symbol).width() as u16)) + }) + .unwrap_or(excerpt.x); + let mut x = end.min(excerpt.right() - 1); + // Do not leave half of a wide character beneath the ellipsis. + if x > excerpt.x + && Span::raw(buffer.cell((x - 1, y)).unwrap().symbol()).width() > 1 + { + x -= 1; + } + buffer + .cell_mut((x, y)) + .unwrap() + .set_symbol("…") + .set_style(Style::new()); } - buffer - .cell_mut((x, y)) - .unwrap() - .set_symbol("…") - .set_style(Style::new().dim()); } frame.render_widget( block.title(loading_title( - " Thumbnail ", - "· t/Esc close ", - (self.thumbnail.is_loading() || self.description_job.is_some()) - .then_some(spinner), + if mode == PreviewMode::Info { + " Info " + } else { + " Description " + }, + if mode == PreviewMode::Info { + "· d description · Esc close " + } else { + "· ↑/↓ PgUp/PgDn · d info · Esc close " + }, + (self.description_job.is_some() + || (mode == PreviewMode::Info && self.thumbnail.is_loading())) + .then_some(spinner), )), popup, ); @@ -2065,7 +2032,7 @@ mod tests { r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Heading","channel":"Creator","upload_date":"20050423"}]}"#, ).unwrap().videos; app.views[0].selection.select(Some(0)); - app.thumbnail_open = true; + app.preview = Some(PreviewMode::Info); for (last_line, hidden, marker) in [ ("line", false, None), ("line", true, Some(50)), @@ -2121,7 +2088,7 @@ mod tests { format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)), ); app.views[0].selection.select(Some(0)); - app.thumbnail_open = true; + app.preview = Some(PreviewMode::Info); for (width, height) in [(80, 24), (120, 40), (20, 10)] { let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); terminal @@ -2160,7 +2127,7 @@ mod tests { .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::DIM)); assert!(!excerpt.modifier.contains(Modifier::BOLD)); } } @@ -2444,12 +2411,11 @@ mod tests { assert!(!app.can_prefetch()); app.mode = Mode::Normal; assert!(app.can_prefetch()); - app.description_open = true; + app.preview = Some(PreviewMode::Description); assert!(!app.can_prefetch()); - app.description_open = false; - app.thumbnail_open = true; + app.preview = Some(PreviewMode::Info); assert!(!app.can_prefetch()); - app.thumbnail_open = false; + app.preview = None; app.category_dialog = Some(CategoryDialog { target: CategoryTarget::Channel { channel: String::new(), @@ -3123,41 +3089,84 @@ mod tests { } #[test] - fn thumbnails_are_normal_mode_only_and_other_popups_dismiss_them() { - let mut app = app(&["tubular"]); - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)); - assert_eq!(app.query, "t"); - assert!(!app.thumbnail_open); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE)); - assert!(!app.thumbnail_open); + fn preview_cycles_without_reloading_and_selection_resets_scrolling() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = app(&["tubular"]); 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":"First"},{"id":"9bZkp7q19f0","title":"Second"}]}"#, ).unwrap().videos; - app.remember_description("jNQXAC9IVRw".into(), "A cached full description.".into()); + // Invalid fixture IDs keep thumbnail failure deterministic without invoking curl. + app.views[0].videos[0].id = "first".into(); + app.views[0].videos[1].id = "second".into(); + app.remember_description("first".into(), "First paragraph\n\n".repeat(40)); + app.remember_description("second".into(), "Second paragraph\n\n".repeat(40)); app.views[0].selection.select(Some(0)); app.views[0].loaded = true; - app.thumbnail_open = true; - app.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE)); - assert!(app.description_open); - assert!(!app.thumbnail_open); + let mut terminal = Terminal::new(TestBackend::new(120, 40)).unwrap(); + let press = |app: &mut App, code| { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); + }; + press(&mut app, KeyCode::Char('t')); + assert_eq!(app.preview, None); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.preview, Some(PreviewMode::Info)); assert!(app.description_job.is_none()); - - app.thumbnail_open = true; - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!app.description_open); - assert!(!app.thumbnail_open); - assert_eq!(app.mode, Mode::Normal); - app.thumbnail_open = true; - app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); - assert!(app.category_dialog.is_some()); - assert!(!app.thumbnail_open); - app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); - app.thumbnail_open = true; - app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); - assert_eq!(app.mode, Mode::Insert); - assert!(!app.thumbnail_open); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let info = terminal.backend().buffer().clone(); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.preview, Some(PreviewMode::Description)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + press(&mut app, KeyCode::PageDown); + assert!(app.description_scroll > 0); + let scroll = app.description_scroll; + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.preview, Some(PreviewMode::Info)); + assert_eq!(app.description_scroll, scroll); + assert!(app.description_job.is_none()); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + // Includes the failed thumbnail text: cancellation would remove that image state. + assert_eq!(terminal.backend().buffer(), &info); + press(&mut app, KeyCode::Char('d')); + press(&mut app, KeyCode::Char('j')); + assert_eq!(app.preview, Some(PreviewMode::Description)); + assert_eq!(app.views[0].selection.selected(), Some(1)); + assert_eq!(app.description_scroll, 0); + assert_eq!(app.description_viewport, None); + assert!(app.description_error.is_none()); + assert!(app.description_job.is_none()); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let popup = preview_area(buffer.area); + let caption: String = (popup.x..popup.right()) + .map(|x| buffer[(x, popup.bottom() - 1)].symbol()) + .collect(); + assert!(caption.contains(" Second ")); + press(&mut app, KeyCode::Down); + assert_eq!(app.description_scroll, 1); + assert_eq!(app.views[0].selection.selected(), Some(1)); + press(&mut app, KeyCode::Char('d')); + press(&mut app, KeyCode::Up); + assert_eq!(app.views[0].selection.selected(), Some(0)); + assert_eq!(app.description_scroll, 0); + press(&mut app, KeyCode::Esc); + assert_eq!(app.preview, None); + assert_eq!( + cached_description(&app.descriptions, "first"), + Some("First paragraph\n\n".repeat(40).as_str()) + ); + press(&mut app, KeyCode::Char('i')); + press(&mut app, KeyCode::Char('t')); + press(&mut app, KeyCode::Char('d')); + assert_eq!(app.query, "td"); + assert_eq!(app.preview, None); } } diff --git a/src/thumbnail.rs b/src/thumbnail.rs index a636458..e05dc2f 100644 --- a/src/thumbnail.rs +++ b/src/thumbnail.rs @@ -11,7 +11,7 @@ use ratatui::{ widgets::{Paragraph, Wrap}, }; use ratatui_image::{ - FilterType, Resize, StatefulImage, + FilterType, FontSize, Resize, StatefulImage, picker::Picker, thread::{ResizeRequest, ResizeResponse, ThreadProtocol}, }; @@ -112,25 +112,26 @@ impl Thumbnail { .is_some_and(|image| image.complete && image.protocol.protocol_type().is_some()) } - /// Draw the image left-aligned and return its occupied width for adjacent content. - pub fn draw(&mut self, frame: &mut Frame, mut area: Rect) -> u16 { + /// Draw the image left-aligned and reserve its fitted width for adjacent content. + pub fn draw(&mut self, frame: &mut Frame, area: Rect) -> u16 { self.poll(); if area.width == 0 || area.height == 0 || self.selected.is_none() { return area.width; } + let slot = left_aligned(area, thumbnail_size(area, self.picker.font_size())); let mut message = self.error.as_deref().unwrap_or("Loading thumbnail..."); if let Some(image) = self.image.as_mut() { let resize = Resize::Scale(Some(FilterType::Triangle)); if let Some(size) = image.protocol.size_for(resize.clone(), area.into()) { - area = left_aligned(area, size); + let image_area = left_aligned(area, size); frame.render_stateful_widget( StatefulImage::new().resize(resize), - area, + image_area, &mut image.protocol, ); if image.protocol.protocol_type().is_some() { - return area.width; + return slot.width; } } message = "Preparing thumbnail..."; @@ -142,13 +143,13 @@ impl Thumbnail { .alignment(Alignment::Center) .wrap(Wrap { trim: true }), Rect::new( - area.x, - area.y + area.height / 2, - area.width, - area.height - area.height / 2, + slot.x, + slot.y + slot.height / 2, + slot.width, + slot.height - slot.height / 2, ), ); - area.width + slot.width } fn poll(&mut self) { @@ -253,6 +254,29 @@ fn decode(bytes: Vec) -> Result { .map_err(|error| format!("Could not decode thumbnail JPEG: {error}")) } +fn thumbnail_size(area: Rect, font: FontSize) -> Size { + if area.width == 0 || area.height == 0 || font.width == 0 || font.height == 0 { + return Size::default(); + } + // HQ thumbnails are 480x360. Match Scale's nearest-pixel rounding, then + // round up to cells without allocating a placeholder image. + let width = u64::from(area.width) * u64::from(font.width); + let height = u64::from(area.height) * u64::from(font.height); + let (width, height) = if width * 3 <= height * 4 { + (width, ((width * 3 + 2) / 4).max(1)) + } else { + (((height * 4 + 1) / 3).max(1), height) + }; + Size::new( + width + .div_ceil(u64::from(font.width)) + .min(u64::from(area.width)) as u16, + height + .div_ceil(u64::from(font.height)) + .min(u64::from(area.height)) as u16, + ) +} + fn left_aligned(area: Rect, size: Size) -> Rect { let width = size.width.min(area.width); let height = size.height.min(area.height); @@ -263,6 +287,81 @@ fn left_aligned(area: Rect, size: Size) -> Rect { mod tests { use super::*; + #[test] + fn loading_thumbnail_keeps_the_same_text_column_as_the_ready_image() { + use ratatui::{Terminal, backend::TestBackend}; + use std::time::Duration; + let mut thumbnail = Thumbnail::new(); + thumbnail.selected = Some("fixture".into()); + let area = Rect::new(2, 2, 80, 8); + let mut terminal = Terminal::new(TestBackend::new(100, 20)).unwrap(); + let mut loading_width = 0; + terminal + .draw(|frame| loading_width = thumbnail.draw(frame, area)) + .unwrap(); + let (_decode_tx, decode_rx) = mpsc::channel(); + thumbnail.decoder = Some(decode_rx); + let mut decoding_width = 0; + terminal + .draw(|frame| decoding_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, decoding_width); + thumbnail.decoder = None; + let (requests_tx, requests_rx) = mpsc::channel::(); + let (replies_tx, replies_rx) = mpsc::channel(); + thumbnail.image = Some(ThumbnailImage { + id: "fixture".into(), + protocol: ThreadProtocol::new( + requests_tx, + Some( + thumbnail + .picker + .new_resize_protocol(image::DynamicImage::new_rgb8(48, 36)), + ), + ), + replies: replies_rx, + complete: false, + }); + let mut preparing_width = 0; + terminal + .draw(|frame| preparing_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, preparing_width); + terminal + .draw(|frame| preparing_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, preparing_width); + replies_tx + .send(Ok(requests_rx + .recv_timeout(Duration::from_secs(2)) + .unwrap() + .resize_encode() + .unwrap())) + .unwrap(); + let mut ready_width = 0; + terminal + .draw(|frame| ready_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!( + loading_width, ready_width, + "right-hand content must not move when the image finishes loading" + ); + thumbnail.cancel(); + thumbnail.select("fixture"); + terminal + .draw(|frame| ready_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, ready_width); + replies_tx + .send(Err("fixture renderer failure".into())) + .unwrap(); + let mut error_width = 0; + terminal + .draw(|frame| error_width = thumbnail.draw(frame, area)) + .unwrap(); + assert_eq!(loading_width, error_width); + assert!(thumbnail.error.is_some()); + } #[test] fn rejects_corrupt_and_oversized_jpeg_input() { assert!(decode(b"not a JPEG".to_vec()).is_err()); @@ -377,13 +476,25 @@ mod tests { complete: false, }); let mut terminal = Terminal::new(TestBackend::new(100, 40)).unwrap(); - for area in [Rect::new(3, 2, 80, 18), Rect::new(7, 4, 30, 8)] { + for area in [ + Rect::new(3, 2, 80, 18), + Rect::new(7, 4, 30, 8), + Rect::new(3, 2, 9, 18), + Rect::new(7, 4, 30, 8), + ] { + let mut pending_width = 0; terminal .draw(|frame| { - thumbnail.draw(frame, area); + pending_width = thumbnail.draw(frame, area); }) .unwrap(); assert!(thumbnail.is_loading()); + assert!( + !terminal.backend().buffer().content.iter().any(|cell| { + matches!(cell.fg, Color::Rgb(..)) || matches!(cell.bg, Color::Rgb(..)) + }), + "a resize must not render stale pixels" + ); let response = requests_rx .recv_timeout(Duration::from_secs(2)) .unwrap() @@ -397,6 +508,7 @@ mod tests { }) .unwrap(); assert!(!thumbnail.is_loading()); + assert_eq!(pending_width, occupied_width); let buffer = terminal.backend().buffer(); let pixels: Vec<_> = (0..40) .flat_map(|y| (0..100).map(move |x| (x, y)))