Files
tubular/src/main.rs
T

4986 lines
212 KiB
Rust

//! A Ratatui YouTube browser backed by yt-dlp, with playback handed to Terebi.
//!
//! Run `cargo run -- "rust programming"`; default to the installed Terebi CLI on PATH.
//! Key instructions below describe defaults. `[keys]` configures all actions; live hints use overrides.
//! `--default-config` prints complete TOML without loading config or starting the terminal.
//! Always start in normal mode; a CLI query searches automatically. Press i to edit the query.
//! Insert: Enter submits a changed query and returns to normal; Esc returns without searching.
//! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word.
//! Cmd/Super+Backspace or Ctrl-U clears the line; plain Backspace removes one character.
//! The terminal must transmit modified Backspace; otherwise map it to Ctrl-W or Ctrl-U.
//! Active text fields show a steady block cursor at the scrolled end; other views hide it.
//! The terminal's configured default cursor shape is restored when the app exits.
//! Normal: j/k selects, Enter opens the video in Terebi, and i edits the query.
//! 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 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, Ctrl-J/K, and PgUp/PgDn; plain j/k selects videos.
//! 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.
//! HQ thumbnails reserve their proportional 4:3 footprint before loading, avoiding metadata reflow.
//! Thumbnails have one column of left padding and two columns before the left-aligned text.
//! A blank row separates the bold title from the dimmed author · publication date.
//! Full descriptions scroll metadata and paragraphs together, with a leading blank row and one-column side padding.
//! 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.
//! 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.
//! Checked tags save the creator's @handle; `[channels]` retains its canonical URL.
//! Mapped handles resolve to stable channel IDs; unmapped handles still work directly.
//! Deselecting removes the channel from that tag, preserving other creators and empty tags.
//! Missing handle/ID metadata or a conflicting mapping prevents saving rather than guessing.
//! Normal-mode s edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels.
//! i filters tags case-insensitively; arrows or Ctrl-J/K move through matches while typing.
//! Enter/Esc leaves filter editing without saving; hidden staged memberships remain unchanged.
//! n prompts for a new tag; Enter stages it checked, clears the filter, and selects it. Esc abandons the name.
//! Normal-mode S in a category view edits every saved channel, even with no videos loaded.
//! Shared tags start checked; Enter atomically replaces every target's tags with the checked set.
//! Unchecked tags lose only target channels; other creators, registrations, pins, and empty categories remain.
//! n stages a new shared tag; Esc cancels the whole edit. Changed source membership requires reopening.
//! Clearing all tags is refused for category-only channels, avoiding accidental unsubscription.
//! Search and Feed are permanent tabs. Normal-mode c opens the saved-category browser.
//! The category browser starts NORMAL: j/k or arrows move, i filters, Enter browses, p pins, Esc closes.
//! INSERT filters live with case-insensitive substring matching; arrows or Ctrl-J/K move, Enter browses.
//! Esc returns to NORMAL without clearing the filter; another Esc closes. Search input editing shortcuts apply.
//! Matching selections survive filter edits; no matches leaves Enter/p without an action.
//! Pinned categories stay visible; browsing another category replaces the single temporary tab.
//! Pinned category tab titles use a dimmed `p` suffix; Search, Feed, and temporary tabs do not.
//! The suffix stays dimmed when selected and counts toward whole-label wrapping without changing category names.
//! Browsing a pinned category focuses its existing tab and removes the temporary tab.
//! `[settings].pinned_categories = ["Favorites"]` persists pins; missing category names are ignored.
//! Settings, channels, categories and keys tables can appear in any order. Move legacy root pins into settings.
//! Tab/Shift-Tab cycle only visible tabs; hidden views retain cached rows and selection.
//! In normal mode Esc or minus goes back through visited views, restoring cached rows and selection.
//! Plus goes forward after going back; a new view navigation discards the old forward branch.
//! Esc closes popups or leaves insert mode first; missing historical views are skipped.
//! Normal-mode ? overlays a scrollable keybinding reference without changing the view or dialog.
//! Help scrolls with j/k, arrows, Ctrl-J/K and page keys; ? or Esc closes it. Text inputs keep literal ?.
//! Normal-mode u opens a searchable saved-channel selector with the category browser's modal controls.
//! Normal-mode U opens the selected video's creator directly, without saving a subscription.
//! Choices include untagged registrations and category-only channels, deduplicated with readable handle labels.
//! Channels and unpinned categories share one temporary tab; hidden views retain caches and back history.
//! Channel views use the same pagination but extract only their stored channel identity; S stays category-only.
//! Config changes preserve unsaved browsing, relabel saved identities, and drop explicitly removed subscriptions.
//! r reloads config and refreshes the current view.
//! Feed includes saved `[channels]` entries, even untagged, plus direct tag entries, without duplicates.
//! Untagging is not unsubscribing: remove mappings and tag references from the file to unsubscribe.
//! Search pages contain 20 results; Feed/tag pages request 20 uploads per channel's /videos tab.
//! Invalid individual video entries are discarded; raw entry counts still determine pagination.
//! Invalid JSON, playlist-level metadata, and extractor failures remain page errors.
//! 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.
//! 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.
//! Errors keep loaded rows and stop automatic retries; r reloads config and restarts the current view.
//! Deep pages may slow down because yt-dlp offsets re-traverse earlier continuation pages.
//! Video tables show Published as YYYY-MM-DD; ~ marks estimated UTC dates, Unknown missing metadata.
//! Prefer exact upload dates and cache date labels during extraction, not on every redraw.
//! Terebi receives `--new-instance --url` and the canonical YouTube URL; each selection gets its own player.
//! `--player` overrides its CLI path; otherwise `terebi` is resolved through PATH.
//! The CLI exits after handoff, not playback completion. Later playback errors appear in Terebi.
//! Quitting cancels pending metadata/launch processes, not the separately running player.
mod config;
mod keys;
mod thumbnail;
mod youtube;
use std::{
collections::{BTreeMap, HashSet},
ffi::OsString,
io,
path::PathBuf,
time::{Duration, Instant},
};
use clap::Parser;
use keys::{Action, Context};
use ratatui::{
DefaultTerminal, Frame,
crossterm::{
cursor::SetCursorStyle,
event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers},
execute,
},
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::{Line, Span, Text},
widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap},
};
use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
#[derive(Parser)]
#[command(
version,
about = "Browse YouTube and open selected videos in Terebi",
after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nRequires 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.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category/channel filters, 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; u browses saved channels; U opens the selected video's channel; 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; Esc or - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. 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.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nChannels and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\nHidden category and channel 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:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\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.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\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, clears the filter, selects the new tag, 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 have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and 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.
#[arg(long, default_value = "terebi")]
player: OsString,
/// Print the complete default TOML configuration and exit.
#[arg(long, conflicts_with = "query")]
default_config: bool,
/// Optional initial YouTube search.
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,
Normal,
}
#[derive(Clone, Debug, PartialEq, Eq)]
enum ViewSource {
Search,
Feed,
Category(String),
Channel(String),
}
impl ViewSource {
fn channels<'a>(&'a self, config: &'a config::Config, feed: &'a [String]) -> &'a [String] {
match self {
Self::Search => &[],
Self::Feed => feed,
Self::Category(name) => &config.categories[name],
Self::Channel(identity) => std::slice::from_ref(identity),
}
}
}
struct View {
source: ViewSource,
name: String,
title: String,
videos: Vec<Video>,
selection: TableState,
loaded: bool,
next_start: usize,
has_more: bool,
pending: Vec<Video>,
page_error: Option<String>,
}
impl View {
fn new(name: String, source: ViewSource) -> Self {
Self {
title: format!(" {name} "),
source,
name,
videos: Vec::new(),
selection: TableState::default(),
loaded: false,
next_start: 1,
has_more: false,
pending: Vec::new(),
page_error: None,
}
}
fn reset(&mut self) {
self.videos.clear();
self.selection = TableState::default();
self.loaded = false;
self.next_start = 1;
self.has_more = false;
self.pending.clear();
self.page_error = None;
}
fn accept_page(&mut self, mut page: Page, feed: bool) -> Result<(), String> {
let next_start = self
.next_start
.checked_add(PAGE_SIZE)
.ok_or("Page offset exceeds the supported range")?;
let mut videos = if feed {
self.pending.append(&mut page.videos);
std::mem::take(&mut self.pending)
} else {
page.videos
};
if feed {
videos.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
}
if !videos.is_empty() {
// 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()));
videos
.iter()
.map(|video| ids.insert(video.id.as_str()))
.collect()
};
let mut keep = keep.into_iter();
videos.retain(|_| keep.next().expect("one deduplication flag per video"));
}
let ready = if feed && page.has_more {
page.cutoff.map_or(0, |cutoff| {
videos.partition_point(|video| video.timestamp.is_some_and(|date| date >= cutoff))
})
} else {
videos.len()
};
self.videos.extend(videos.drain(..ready));
if feed {
self.pending = videos;
}
if self.selection.selected().is_none() && !self.videos.is_empty() {
self.selection.select(Some(0));
}
self.loaded = true;
self.next_start = next_start;
self.has_more = page.has_more;
self.page_error = None;
Ok(())
}
fn needs_page(&self) -> bool {
self.loaded
&& self.has_more
&& self.page_error.is_none()
&& (self.videos.is_empty()
|| self
.selection
.selected()
.is_some_and(|index| index.saturating_add(5) >= self.videos.len()))
}
}
enum CategoryTarget {
Channel { handle: String, channel: String },
Category { name: String, channels: Vec<String> },
}
struct CategoryDialog {
target: CategoryTarget,
label: String,
tags: BTreeMap<String, bool>,
mode: Mode,
query: String,
search_names: Vec<String>,
matches: Vec<usize>,
selection: ListState,
name: Option<String>,
error: Option<String>,
}
impl CategoryDialog {
fn new(target: CategoryTarget, label: String, tags: BTreeMap<String, bool>) -> Self {
let mut dialog = Self {
target,
label,
search_names: tags.keys().map(|name| name.to_lowercase()).collect(),
matches: Vec::with_capacity(tags.len()),
tags,
mode: Mode::Normal,
query: String::new(),
selection: ListState::default(),
name: None,
error: None,
};
dialog.refilter();
dialog
}
fn refilter(&mut self) {
filter_choices(
&self.query,
&self.search_names,
&mut self.matches,
&mut self.selection,
);
self.error = None;
}
}
fn filter_choices(
query: &str,
search_names: &[String],
matches: &mut Vec<usize>,
selection: &mut ListState,
) {
let selected = selection
.selected()
.and_then(|row| matches.get(row))
.copied();
let query = query.to_lowercase();
matches.clear();
matches.extend(
search_names
.iter()
.enumerate()
.filter(|(_, name)| name.contains(&query))
.map(|(index, _)| index),
);
selection.select(
selected
.and_then(|index| matches.binary_search(&index).ok())
.or_else(|| (!matches.is_empty()).then_some(0)),
);
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BrowserKind {
Categories,
Channels,
}
struct Browser {
kind: BrowserKind,
choices: Vec<(String, ViewSource)>,
mode: Mode,
query: String,
search_names: Vec<String>,
matches: Vec<usize>,
selection: ListState,
error: Option<String>,
}
impl Browser {
fn refilter(&mut self) {
filter_choices(
&self.query,
&self.search_names,
&mut self.matches,
&mut self.selection,
);
self.error = None;
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum PreviewMode {
Info,
Description,
}
struct KeyHints {
footers: [(Context, String); 10],
preview: [String; 2],
browser: [String; 2],
tags: [String; 3],
tags_empty: String,
help: String,
}
impl KeyHints {
fn new(bindings: &keys::Bindings) -> Self {
let key = |action| bindings.label(action);
Self {
footers: [
(
Context::Normal,
format!(
"NORMAL · {}/{} select · {} play · {} search · {} help · {} quit",
key(Action::VideoNext),
key(Action::VideoPrevious),
key(Action::VideoPlay),
key(Action::SearchEdit),
key(Action::Help),
key(Action::Quit)
),
),
(
Context::Info,
format!(
"INFO · {} description · {} close · {} help",
key(Action::InfoDescription),
key(Action::InfoHide),
key(Action::Help)
),
),
(
Context::Description,
format!(
"DESCRIPTION · {}/{} scroll · {} info · {} close · {} help",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
key(Action::InfoHide),
key(Action::Help)
),
),
(
Context::SearchInput,
format!(
"INSERT · {} search · {} normal · {} quit",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::Interrupt)
),
),
(
Context::SelectorNormal,
format!(
"BROWSE · {}/{} move · {} filter · {} browse · {} close · {} help",
key(Action::SelectorNext),
key(Action::SelectorPrevious),
key(Action::SelectorEdit),
key(Action::SelectorOpen),
key(Action::SelectorClose),
key(Action::Help)
),
),
(
Context::SelectorInput,
format!(
"FILTER INSERT · {}/{} move · {} browse · {} normal",
key(Action::SelectorInputNext),
key(Action::SelectorInputPrevious),
key(Action::InputSubmit),
key(Action::InputCancel)
),
),
(
Context::TagList,
format!(
"TAGS · {} filter · {} toggle · {} save · {} cancel · {} help",
key(Action::TagEdit),
key(Action::TagToggle),
key(Action::TagSave),
key(Action::TagCancel),
key(Action::Help)
),
),
(
Context::TagFilter,
format!(
"TAG FILTER INSERT · {}/{} move · {}/{} normal",
key(Action::TagInputNext),
key(Action::TagInputPrevious),
key(Action::InputSubmit),
key(Action::InputCancel)
),
),
(
Context::TagInput,
format!(
"NEW TAG · {} stage · {} cancel · {} quit",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::Interrupt)
),
),
(
Context::Help,
format!(
"HELP · {}/{} scroll · {}/{} page · {}/{} close",
key(Action::HelpNext),
key(Action::HelpPrevious),
key(Action::HelpPageNext),
key(Action::HelpPagePrevious),
key(Action::Help),
key(Action::HelpClose)
),
),
],
preview: [
format!(
"· {} description · {} close ",
key(Action::InfoDescription),
key(Action::InfoHide)
),
format!(
"· {}/{} scroll · {} info · {} close ",
key(Action::DescriptionNext),
key(Action::DescriptionPrevious),
key(Action::InfoOverview),
key(Action::InfoHide)
),
],
browser: [
format!(
" Categories · {} filter · {} browse · {} pin ",
key(Action::SelectorEdit),
key(Action::SelectorOpen),
key(Action::SelectorPin)
),
format!(
" Channels · {} filter · {} browse ",
key(Action::SelectorEdit),
key(Action::SelectorOpen)
),
],
tags: [
format!(
"{}/{} move · {} filter · {} toggle · {} save · {} new · {} cancel",
key(Action::TagNext),
key(Action::TagPrevious),
key(Action::TagEdit),
key(Action::TagToggle),
key(Action::TagSave),
key(Action::TagNew),
key(Action::TagCancel)
),
format!(
"{} create · {} back · {} clear",
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::DeleteLine)
),
format!(
"{}/{} move · {}/{} back · {} clear",
key(Action::TagInputNext),
key(Action::TagInputPrevious),
key(Action::InputSubmit),
key(Action::InputCancel),
key(Action::DeleteLine)
),
],
tags_empty: format!("No tags. Press {} to create one.", key(Action::TagNew)),
help: format!(
" Keybindings · {}/{} close · {}/{} scroll ",
key(Action::Help),
key(Action::HelpClose),
key(Action::HelpNext),
key(Action::HelpPrevious)
),
}
}
fn footer(&self, context: Context) -> &str {
self.footers
.iter()
.find(|(candidate, _)| *candidate == context)
.expect("every input context has a hint")
.1
.as_str()
}
}
#[derive(Default)]
struct HelpPopup {
paragraph: Paragraph<'static>,
scroll: u16,
max_scroll: u16,
viewport: Option<(u16, u16)>,
}
struct App {
options: Options,
query: String,
mode: Mode,
submitted_query: String,
config_path: PathBuf,
config: config::Config,
key_hints: KeyHints,
feed_channels: Vec<String>,
views: Vec<View>,
view_index: usize,
view_history: Vec<ViewSource>,
forward_history: Vec<ViewSource>,
category_dialog: Option<CategoryDialog>,
browser: Option<Browser>,
temporary_view: Option<ViewSource>,
preview: Option<PreviewMode>,
help_popup: Option<HelpPopup>,
thumbnail: thumbnail::Thumbnail,
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)>,
job: Option<Job>,
player_launch: Option<PlayerLaunch>,
status: String,
}
impl App {
fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self {
let query = options.query.clone().unwrap_or_default();
let feed_channels = config.subscriptions();
let key_hints = KeyHints::new(&config.keys);
let mut views = vec![
View::new("Search".into(), ViewSource::Search),
View::new("Feed".into(), ViewSource::Feed),
];
views.extend(
config
.categories
.keys()
.map(|name| View::new(name.clone(), ViewSource::Category(name.clone()))),
);
Self {
options,
query,
mode: Mode::Normal,
submitted_query: String::new(),
config_path,
config,
key_hints,
feed_channels,
views,
view_index: 0,
view_history: Vec::new(),
forward_history: Vec::new(),
category_dialog: None,
browser: None,
temporary_view: None,
preview: None,
help_popup: None,
thumbnail: thumbnail::Thumbnail::new(),
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,
job: None,
player_launch: None,
status: "Enter a query to search YouTube.".into(),
}
}
fn visible_views(&self) -> impl Iterator<Item = (usize, &View)> {
self.views
.iter()
.enumerate()
.filter(|(_, view)| match &view.source {
ViewSource::Search | ViewSource::Feed => true,
ViewSource::Category(name) => self.config.pinned_categories.contains(name),
ViewSource::Channel(_) => false,
})
.chain(self.temporary_view.as_ref().and_then(|source| {
self.views.iter().enumerate().find(|(_, view)| {
&view.source == source
&& !matches!(source, ViewSource::Category(name) if self.config.pinned_categories.contains(name))
})
}))
}
fn cycle_view(&mut self, backwards: bool) {
let next = if backwards {
self.visible_views()
.map(|(index, _)| index)
.take_while(|index| *index != self.view_index)
.last()
.or_else(|| self.visible_views().last().map(|(index, _)| index))
.unwrap_or(0)
} else {
self.visible_views()
.map(|(index, _)| index)
.skip_while(|index| *index != self.view_index)
.nth(1)
.unwrap_or(0)
};
self.switch_view(next);
}
fn open_browser(&mut self, kind: BrowserKind) {
self.close_preview();
let choices: Vec<_> = match kind {
BrowserKind::Categories => self
.config
.categories
.keys()
.map(|name| (name.clone(), ViewSource::Category(name.clone())))
.collect(),
BrowserKind::Channels => self
.config
.saved_channels()
.into_iter()
.map(|(label, identity)| (label, ViewSource::Channel(identity)))
.collect(),
};
let selected = choices
.iter()
.position(|(_, source)| source == &self.views[self.view_index].source)
.or_else(|| (!choices.is_empty()).then_some(0));
let mut browser = Browser {
kind,
mode: Mode::Normal,
query: String::new(),
search_names: choices
.iter()
.map(|(label, _)| label.to_lowercase())
.collect(),
matches: Vec::with_capacity(choices.len()),
choices,
selection: ListState::default(),
error: None,
};
browser.refilter();
browser.selection.select(selected);
self.browser = Some(browser);
}
fn browse_key(&mut self, key: KeyEvent, action: Option<Action>) {
let mut browser = self.browser.take().expect("browser is open");
let action = if browser.mode == Mode::Insert {
match action {
Some(Action::InputCancel) => {
browser.mode = Mode::Normal;
self.browser = Some(browser);
return;
}
Some(Action::InputSubmit) => Some(Action::SelectorOpen),
Some(Action::SelectorInputNext) => Some(Action::SelectorNext),
Some(Action::SelectorInputPrevious) => Some(Action::SelectorPrevious),
_ => {
edit_input(&mut browser.query, key, action);
browser.refilter();
self.browser = Some(browser);
return;
}
}
} else {
action
};
let count = browser.matches.len();
match action {
Some(Action::SelectorClose) => return,
Some(Action::SelectorEdit) => browser.mode = Mode::Insert,
Some(Action::SelectorNext) if count != 0 => {
browser.selection.select(Some(
browser
.selection
.selected()
.unwrap_or(0)
.saturating_add(1)
.min(count - 1),
));
}
Some(Action::SelectorPrevious) if count != 0 => {
browser.selection.select(Some(
browser.selection.selected().unwrap_or(0).saturating_sub(1),
));
}
Some(Action::SelectorOpen | Action::SelectorPin)
if action == Some(Action::SelectorOpen)
|| browser.kind == BrowserKind::Categories =>
{
if let Some((name, source)) = browser
.selection
.selected()
.and_then(|row| browser.matches.get(row))
.and_then(|index| browser.choices.get(*index))
.cloned()
{
if action == Some(Action::SelectorOpen) {
self.browse_view(name, source);
return;
}
let pinned = !self.config.pinned_categories.contains(&name);
match config::set_category_pinned(&self.config_path, &name, pinned) {
Ok(config) => {
let names_changed =
config.categories.keys().ne(self.config.categories.keys());
self.apply_config(config);
self.switch_view(self.view_index);
if names_changed {
browser.choices = self
.config
.categories
.keys()
.map(|name| (name.clone(), ViewSource::Category(name.clone())))
.collect();
browser.search_names = browser
.choices
.iter()
.map(|(label, _)| label.to_lowercase())
.collect();
browser.refilter();
browser.selection.select(
self.config
.categories
.keys()
.position(|key| key == &name)
.and_then(|index| {
browser.matches.binary_search(&index).ok()
}),
);
}
browser.error = None;
}
Err(error) => browser.error = Some(error.to_string()),
}
}
}
_ => {}
}
self.browser = Some(browser);
}
fn browse_view(&mut self, name: String, source: ViewSource) {
if matches!(source, ViewSource::Category(_)) {
self.temporary_view = None;
}
let index = self
.views
.iter()
.position(|view| view.source == source)
.unwrap_or_else(|| {
self.views.push(View::new(name, source));
self.views.len() - 1
});
self.switch_view(index);
}
fn open_video_channel(&mut self) {
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
self.status = "Select a video first.".into();
return;
};
if matches!(view.source, ViewSource::Channel(_)) {
self.switch_view(self.view_index);
return;
}
let handle = video
.uploader_id
.as_deref()
.filter(|handle| handle.starts_with('@'))
.and_then(|handle| {
config::normalize_channel(handle)
.ok()
.map(|url| (handle, url))
});
let identity = if let Some(id) = video.channel_id.as_deref() {
config::normalize_channel(id)
} else if let Some((handle, url)) = &handle {
Ok(self
.config
.channels
.get(*handle)
.cloned()
.unwrap_or_else(|| url.clone()))
} else {
self.status = "This video's channel identity is unavailable.".into();
return;
};
let identity = match identity {
Ok(identity) => identity,
Err(error) => {
self.status = error.to_string();
return;
}
};
let saved = self
.config
.saved_channels()
.into_iter()
.find(|(_, channel)| {
channel == &identity || handle.as_ref().is_some_and(|(_, url)| channel == url)
});
let (label, identity) = saved.unwrap_or_else(|| {
let label = handle
.as_ref()
.map(|(handle, _)| *handle)
.or(video.channel.as_deref())
.unwrap_or(&identity)
.to_owned();
(label, identity)
});
self.browse_view(label, ViewSource::Channel(identity));
}
fn search(&mut self) {
let query = self.query.trim();
if query.is_empty() {
self.status = "Enter a query to search YouTube.".into();
return;
}
if query != self.submitted_query {
self.submitted_query = query.to_owned();
self.views[0].reset();
self.job = None;
}
self.switch_view(0);
}
fn switch_view(&mut self, index: usize) {
if index != self.view_index {
self.view_history
.push(self.views[self.view_index].source.clone());
self.forward_history.clear();
}
self.activate_view(index);
}
fn back_view(&mut self) {
while let Some(source) = self.view_history.pop() {
if let Some(index) = self.views.iter().position(|view| view.source == source)
&& index != self.view_index
{
self.forward_history
.push(self.views[self.view_index].source.clone());
self.activate_view(index);
return;
}
}
}
fn forward_view(&mut self) {
while let Some(source) = self.forward_history.pop() {
if let Some(index) = self.views.iter().position(|view| view.source == source)
&& index != self.view_index
{
self.view_history
.push(self.views[self.view_index].source.clone());
self.activate_view(index);
return;
}
}
}
fn activate_view(&mut self, index: usize) {
let source = &self.views[index].source;
if matches!(source, ViewSource::Channel(_))
|| matches!(source, ViewSource::Category(name) if !self.config.pinned_categories.contains(name))
{
self.temporary_view = Some(source.clone());
}
if index != self.view_index {
self.job = None;
self.view_index = index;
}
self.close_preview();
if !self.views[index].loaded && self.views[index].page_error.is_none() {
if self.job.is_none() {
self.load_page();
}
} else if self.job.is_none() {
self.status = self.view_status();
}
}
fn load_view(&mut self) {
self.job = None;
self.close_preview();
self.views[self.view_index].reset();
self.load_page();
}
fn load_page(&mut self) {
if self.job.is_some() {
return;
}
let view = &mut self.views[self.view_index];
let page = (view.next_start - 1) / PAGE_SIZE + 1;
let result = if view.source == ViewSource::Search {
if self.submitted_query.is_empty() {
self.status = "Enter a query to search YouTube.".into();
return;
}
self.status = format!("Searching for {} (page {page})...", self.submitted_query);
Job::search(&self.submitted_query, view.next_start)
} else {
let channels = view.source.channels(&self.config, &self.feed_channels);
if channels.is_empty() {
view.loaded = true;
view.has_more = false;
self.status = format!(
"{} has no channels. Add channels from search results or edit config.",
view.name
);
return;
}
self.status = format!(
"Loading {} page {page} ({} videos kept)...",
view.name,
view.videos.len()
);
Job::feed(channels, view.next_start)
};
match result {
Ok(job) => self.job = Some(job),
Err(error) => {
view.page_error = Some(error.to_string());
self.status = self.view_status();
}
}
}
fn view_status(&self) -> String {
let view = &self.views[self.view_index];
if let Some(error) = &view.page_error {
return format!(
"{} videos kept in {}. Page {} failed: {error}. Refresh this view to retry.",
view.videos.len(),
view.name,
(view.next_start - 1) / PAGE_SIZE + 1
);
}
if view.source != ViewSource::Search
&& view
.source
.channels(&self.config, &self.feed_channels)
.is_empty()
{
return format!(
"{} has no channels. Add channels from search results or edit config.",
view.name
);
}
let continuation = if view.has_more {
if view.videos.is_empty() && !view.pending.is_empty() {
"newer pages pending"
} else {
"more available"
}
} else {
"end"
};
format!(
"{} videos in {} (page {}; {continuation}). Select a video to stream.",
view.videos.len(),
view.name,
(view.next_start - 1) / PAGE_SIZE
)
}
fn finish_page(&mut self, result: Result<String, String>) {
self.job = None;
let parsed = result.and_then(|output| {
if self.views[self.view_index].source == ViewSource::Search {
youtube::search_results(&output)
} else {
youtube::feed_results(&output)
}
});
let feed = self.views[self.view_index].source != ViewSource::Search;
if let Err(error) =
parsed.and_then(|page| self.views[self.view_index].accept_page(page, feed))
{
self.views[self.view_index].page_error = Some(error);
}
self.status = self.view_status();
}
fn can_prefetch(&self) -> bool {
self.job.is_none()
&& self.mode == Mode::Normal
&& self.category_dialog.is_none()
&& self.browser.is_none()
&& self.preview.is_none()
&& self.help_popup.is_none()
&& self.views[self.view_index].needs_page()
}
fn apply_config(&mut self, config: config::Config) {
if config.keys != self.config.keys {
self.key_hints = KeyHints::new(&config.keys);
if let Some(help) = &mut self.help_popup {
help.paragraph = Paragraph::new(config.keys.help_text()).wrap(Wrap { trim: false });
help.viewport = None;
}
}
if config.categories == self.config.categories
&& config.channels == self.config.channels
&& config.pinned_categories == self.config.pinned_categories
{
self.config.keys = config.keys;
return;
}
let active_source = self.views[self.view_index].source.clone();
let feed_channels = config.subscriptions();
let mut old = std::mem::take(&mut self.views).into_iter();
self.views.push(old.next().expect("search view exists"));
let mut feed = old.next().expect("feed view exists");
if feed_channels != self.feed_channels {
feed.reset();
}
self.views.push(feed);
let mut old: Vec<_> = old.collect();
// ponytail: category lookup is quadratic; use a keyed map if hundreds of categories matter.
for name in config.categories.keys() {
let mut view = if let Some(index) = old.iter().position(
|view| matches!(&view.source, ViewSource::Category(category) if category == name),
) {
old.swap_remove(index)
} else {
View::new(name.clone(), ViewSource::Category(name.clone()))
};
if config.categories.get(name) != self.config.categories.get(name) {
view.reset();
}
self.views.push(view);
}
let saved_channels = config.saved_channels();
for (label, identity) in &saved_channels {
if let Some(index) = old.iter().position(
|view| matches!(&view.source, ViewSource::Channel(channel) if channel == identity),
) {
let mut view = old.swap_remove(index);
if &view.name != label {
view.title = format!(" {label} ");
view.name = label.clone();
}
self.views.push(view);
}
}
// Unsaved channels opened from video rows remain browseable without subscribing.
self.views.extend(old.into_iter().filter(|view| {
matches!(&view.source, ViewSource::Channel(identity) if !self.feed_channels.contains(identity))
}));
self.view_index = self
.views
.iter()
.position(|view| view.source == active_source)
.unwrap_or(0);
self.feed_channels = feed_channels;
self.config = config;
if self.temporary_view.as_ref().is_some_and(|source| {
!self.views.iter().any(|view| &view.source == source)
|| matches!(source, ViewSource::Category(name) if self.config.pinned_categories.contains(name))
}) {
self.temporary_view = None;
}
if let ViewSource::Category(name) = &self.views[self.view_index].source
&& !self.config.pinned_categories.contains(name)
{
self.temporary_view = Some(ViewSource::Category(name.clone()));
}
self.job = None;
self.close_preview();
}
fn refresh(&mut self) {
self.close_preview();
match config::load(&self.config_path) {
Ok(config) => {
self.thumbnail.clear_cache();
self.descriptions.clear();
self.apply_config(config);
self.load_view();
}
Err(error) => self.status = error.to_string(),
}
}
fn open_category_picker(&mut self) {
self.close_preview();
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
self.status = "Select a video first.".into();
return;
};
let Some(id) = video.channel_id.as_deref() else {
self.status = "This video has no available channel identity.".into();
return;
};
let Some(handle) = video
.uploader_id
.as_deref()
.filter(|handle| handle.starts_with('@'))
else {
self.status =
"This video's channel handle is unavailable; no channel was saved.".into();
return;
};
let handle = handle.to_owned();
let channel = format!("https://www.youtube.com/channel/{id}");
let label = format!(" {} ({handle}) ", video.channel.as_deref().unwrap_or(id));
match config::load(&self.config_path) {
Ok(config) => self.apply_config(config),
Err(error) => {
self.status = error.to_string();
return;
}
}
let handle_url = format!("https://www.youtube.com/{handle}");
let tags = self
.config
.categories
.iter()
.map(|(name, channels)| {
(
name.clone(),
channels
.iter()
.any(|url| url == &channel || url == &handle_url),
)
})
.collect();
self.category_dialog = Some(CategoryDialog::new(
CategoryTarget::Channel { handle, channel },
label,
tags,
));
}
fn open_bulk_category_picker(&mut self) {
let ViewSource::Category(name) = &self.views[self.view_index].source else {
self.status = "Browse a category first to edit all its channels.".into();
return;
};
let name = name.clone();
let config = match config::load(&self.config_path) {
Ok(config) => config,
Err(error) => {
self.status = error.to_string();
return;
}
};
let channels = config.categories.get(&name).cloned().unwrap_or_default();
self.apply_config(config);
if channels.is_empty() {
self.status = format!("{name} has no saved channels to edit.");
return;
}
let tags = self
.config
.categories
.iter()
.map(|(tag, members)| {
(
tag.clone(),
channels.iter().all(|channel| members.contains(channel)),
)
})
.collect();
self.close_preview();
let mut dialog = CategoryDialog::new(
CategoryTarget::Category {
name: name.clone(),
channels,
},
format!(
" {name} · {} channels ",
self.config.categories[&name].len()
),
tags,
);
dialog
.selection
.select(self.config.categories.keys().position(|tag| tag == &name));
self.category_dialog = Some(dialog);
}
fn category_key(&mut self, key: KeyEvent, action: Option<Action>) {
let mut dialog = self
.category_dialog
.take()
.expect("category dialog is open");
if let Some(name) = &mut dialog.name {
match action {
Some(Action::InputCancel) => {
dialog.name = None;
dialog.error = None;
}
Some(Action::InputSubmit) => {
let name = name.trim().to_owned();
if name.is_empty() || name.chars().any(char::is_control) {
dialog.error =
Some("Enter a nonempty tag name without control characters.".into());
} else if dialog.tags.contains_key(&name) {
dialog.error = Some("That tag already exists.".into());
} else {
dialog.tags.insert(name.clone(), true);
dialog.search_names =
dialog.tags.keys().map(|tag| tag.to_lowercase()).collect();
dialog.query.clear();
dialog.refilter();
dialog
.selection
.select(dialog.tags.keys().position(|tag| tag == &name));
dialog.name = None;
dialog.error = None;
}
}
_ => edit_input(name, key, action),
}
} else {
let action = if dialog.mode == Mode::Insert {
match action {
Some(Action::InputCancel | Action::InputSubmit) => {
dialog.mode = Mode::Normal;
self.category_dialog = Some(dialog);
return;
}
Some(Action::TagInputNext) => Some(Action::TagNext),
Some(Action::TagInputPrevious) => Some(Action::TagPrevious),
_ => {
edit_input(&mut dialog.query, key, action);
dialog.refilter();
self.category_dialog = Some(dialog);
return;
}
}
} else {
action
};
let count = dialog.matches.len();
match action {
Some(Action::TagEdit) => dialog.mode = Mode::Insert,
Some(Action::TagCancel) => {
self.switch_view(self.view_index);
return;
}
Some(Action::TagNew) => {
dialog.name = Some(String::new());
dialog.error = None;
}
Some(Action::TagToggle) => {
if let Some((_, checked)) = dialog
.selection
.selected()
.and_then(|row| dialog.matches.get(row))
.and_then(|index| dialog.tags.iter_mut().nth(*index))
{
*checked = !*checked;
dialog.error = None;
}
}
Some(Action::TagNext) if count != 0 => {
dialog.selection.select(Some(
dialog
.selection
.selected()
.unwrap_or(0)
.saturating_add(1)
.min(count - 1),
));
}
Some(Action::TagPrevious) if count != 0 => {
dialog.selection.select(Some(
dialog.selection.selected().unwrap_or(0).saturating_sub(1),
));
}
Some(Action::TagSave) => {
let result = match &dialog.target {
CategoryTarget::Channel { handle, channel } => config::set_channel_tags(
&self.config_path,
&dialog.tags,
handle,
channel,
),
CategoryTarget::Category { name, channels } => config::set_category_tags(
&self.config_path,
name,
channels,
&dialog.tags,
),
};
match result {
Ok(config) => {
self.apply_config(config);
self.switch_view(self.view_index);
self.status = match &dialog.target {
CategoryTarget::Channel { .. } => "Saved channel tags.".into(),
CategoryTarget::Category { channels, .. } => {
format!("Saved tags for {} channels.", channels.len())
}
};
return;
}
Err(error) => dialog.error = Some(error.to_string()),
}
}
_ => {}
}
}
self.category_dialog = Some(dialog);
}
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
.selection
.selected()
.filter(|&index| index < view.videos.len())
else {
return;
};
let video = &view.videos[index];
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) {
Ok(job) => self.description_job = Some((index, job)),
Err(error) => self.description_error = Some(error.to_string()),
}
}
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
.selection
.selected()
.and_then(|index| view.videos.get(index))
else {
return;
};
self.player_launch = None;
match PlayerLaunch::start(&video.id, &self.options.player) {
Ok(launch) => {
self.status = format!(
"Opening {} in {}...",
video.title,
self.options.player.to_string_lossy()
);
self.player_launch = Some(launch);
}
Err(error) => self.status = error.to_string(),
}
}
fn update(&mut self) {
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.finish_page(result);
}
if let Some(result) = self
.description_job
.as_mut()
.and_then(|(_, job)| job.poll())
{
let (index, _) = self
.description_job
.take()
.expect("completed description job exists");
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) => self.remember_description(video.id.clone(), description),
Err(error) => self.description_error = Some(error),
}
}
self.description_viewport = None;
}
if let Some(result) = self.player_launch.as_mut().and_then(PlayerLaunch::poll) {
self.player_launch = None;
self.status = match result {
Ok(()) => "Video handed to player; playback continues in its window.".into(),
Err(error) => error,
};
}
if self.can_prefetch() {
self.load_page();
}
}
fn key_context(&self) -> Context {
if self.help_popup.is_some() {
Context::Help
} else if let Some(dialog) = &self.category_dialog {
if dialog.name.is_some() {
Context::TagInput
} else if dialog.mode == Mode::Insert {
Context::TagFilter
} else {
Context::TagList
}
} else if let Some(browser) = &self.browser {
if browser.mode == Mode::Insert {
Context::SelectorInput
} else {
Context::SelectorNormal
}
} else if self.mode == Mode::Insert {
Context::SearchInput
} else {
match self.preview {
Some(PreviewMode::Info) => Context::Info,
Some(PreviewMode::Description) => Context::Description,
None => Context::Normal,
}
}
}
fn key(&mut self, key: KeyEvent) -> bool {
let action = self.config.keys.resolve(self.key_context(), key);
if action == Some(Action::Interrupt) {
return true;
}
if let Some(help) = &mut self.help_popup {
match action {
Some(Action::HelpClose | Action::Help) => self.help_popup = None,
Some(Action::Quit) => return true,
Some(Action::HelpNext) => {
help.scroll = help.scroll.saturating_add(1).min(help.max_scroll);
}
Some(Action::HelpPrevious) => help.scroll = help.scroll.saturating_sub(1),
Some(Action::HelpPageNext) => {
let page = help.viewport.map_or(1, |(_, height)| height.max(1));
help.scroll = help.scroll.saturating_add(page).min(help.max_scroll);
}
Some(Action::HelpPagePrevious) => {
let page = help.viewport.map_or(1, |(_, height)| height.max(1));
help.scroll = help.scroll.saturating_sub(page);
}
_ => {}
}
return false;
}
if action == Some(Action::Help) {
self.help_popup = Some(HelpPopup {
paragraph: Paragraph::new(self.config.keys.help_text()).wrap(Wrap { trim: false }),
..Default::default()
});
return false;
}
if self.category_dialog.is_some() {
self.category_key(key, action);
return false;
}
if self.browser.is_some() {
self.browse_key(key, action);
return false;
}
let selected_before = self.views[self.view_index].selection.selected();
let preview_was_open = self.preview.is_some();
if self.mode == Mode::Insert {
match action {
Some(Action::InputCancel) => self.mode = Mode::Normal,
Some(Action::InputSubmit) => {
self.search();
self.mode = Mode::Normal;
}
_ => edit_input(&mut self.query, key, action),
}
} else {
match action {
Some(Action::Quit) => return true,
Some(Action::SearchEdit) => {
self.close_preview();
self.mode = Mode::Insert;
}
Some(action @ (Action::InfoOverview | Action::InfoDescription)) => {
self.preview = self.views[self.view_index]
.selection
.selected()
.filter(|&index| index < self.views[self.view_index].videos.len())
.map(|_| {
if action == Action::InfoOverview {
PreviewMode::Info
} else {
PreviewMode::Description
}
});
}
Some(Action::InfoHide) => self.close_preview(),
Some(Action::ViewPrevious) => self.back_view(),
Some(Action::ViewNext) => self.forward_view(),
Some(Action::DescriptionNext) => {
self.description_scroll = self
.description_scroll
.saturating_add(1)
.min(self.description_max_scroll);
}
Some(Action::DescriptionPrevious) => {
self.description_scroll = self.description_scroll.saturating_sub(1);
}
Some(Action::DescriptionPageNext) => {
let page = self
.description_viewport
.map(|(_, height)| height)
.unwrap_or(1)
.max(1);
self.description_scroll = self
.description_scroll
.saturating_add(page)
.min(self.description_max_scroll);
}
Some(Action::DescriptionPagePrevious) => {
let page = self
.description_viewport
.map(|(_, height)| height)
.unwrap_or(1)
.max(1);
self.description_scroll = self.description_scroll.saturating_sub(page);
}
Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories),
Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels),
Some(Action::VideoChannel) => self.open_video_channel(),
Some(Action::ChannelTags) => self.open_category_picker(),
Some(Action::CategoryTags) => self.open_bulk_category_picker(),
Some(Action::ViewRefresh) => self.refresh(),
Some(Action::ViewTabNext) => self.cycle_view(false),
Some(Action::ViewTabPrevious) => self.cycle_view(true),
Some(Action::VideoPlay) => self.play(),
Some(Action::VideoNext) => self.views[self.view_index].selection.select_next(),
Some(Action::VideoPrevious) => {
self.views[self.view_index].selection.select_previous()
}
Some(Action::VideoFirst) => self.views[self.view_index].selection.select_first(),
Some(Action::VideoLast) => self.views[self.view_index].selection.select_last(),
_ => {}
}
}
let view = &mut self.views[self.view_index];
if view.videos.is_empty() {
view.selection.select(None);
} else if let Some(index) = view.selection.selected() {
view.selection
.select(Some(index.min(view.videos.len() - 1)));
}
if self.preview.is_none() {
self.description_job = None;
} else if !preview_was_open
|| self.views[self.view_index].selection.selected() != selected_before
{
self.load_description();
}
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
.selected()
.and_then(|index| self.views[self.view_index].videos.get(index))
{
self.thumbnail.select(&video.id);
}
false
}
fn draw(&mut self, frame: &mut Frame, elapsed: Duration) {
const SPINNER: [&str; 10] = ["⠋ ", "⠙ ", "⠹ ", "⠸ ", "⠼ ", "⠴ ", "⠦ ", "⠧ ", "⠇ ", "⠏ "];
let spinner = SPINNER[(elapsed.as_millis() / 100 % SPINNER.len() as u128) as usize];
let area = frame.area();
let mut tab_lines = vec![Line::default()];
let mut row_width = 0;
let mut selected_row = 0;
for (index, view) in self.visible_views() {
let mut title = Span::raw(view.name.as_str());
let pinned = matches!(&view.source, ViewSource::Category(name)
if self.config.pinned_categories.contains(name));
let title_width = title.width() + 2 + if pinned { 2 } else { 0 }; // Padding and pin marker.
if row_width != 0 && row_width + 1 + title_width > usize::from(area.width) {
tab_lines.push(Line::default());
row_width = 0;
}
if index == self.view_index {
title.style = Style::new().fg(Color::Cyan).bold();
selected_row = tab_lines.len() - 1;
}
let row = tab_lines.last_mut().expect("at least one tab row");
if row_width != 0 {
row.spans.push("│".into());
}
row.spans.extend([" ".into(), title]);
if pinned {
row.spans.push(Span::styled(" p", Style::new().dim()));
}
row.spans.push(" ".into());
row_width += usize::from(row_width != 0) + title_width;
}
// Leave space for search, a usable results pane, status, and help.
let tab_height = tab_lines
.len()
.min(usize::from(area.height.saturating_sub(10).max(1)))
as u16;
let first_row = selected_row.saturating_sub(usize::from(tab_height).saturating_sub(1));
let [tabs, search, results, status, help] = Layout::vertical([
Constraint::Length(tab_height),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(area);
frame.render_widget(
Paragraph::new(tab_lines).scroll((first_row.min(u16::MAX as usize) as u16, 0)),
tabs,
);
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
Style::new()
};
// End-only editing; scroll long queries without counting UTF-8 bytes as columns.
let query = ratatui::text::Line::raw(self.query.as_str());
let query_width = query.width();
let search_block = Block::bordered()
.title(" tubular / YouTube search ")
.border_style(search_style);
let input = search_block.inner(search);
let scroll = query_width.saturating_sub(input.width.saturating_sub(1) as usize);
frame.render_widget(
Paragraph::new(query)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(search_block),
search,
);
if self.mode == Mode::Insert
&& self.category_dialog.is_none()
&& input.width != 0
&& input.height != 0
{
let cursor = query_width
.saturating_sub(scroll)
.min(usize::from(input.width - 1));
frame.set_cursor_position((input.x + cursor as u16, input.y));
}
let view = &mut self.views[self.view_index];
let table = Table::new(
view.videos.iter().map(|video| {
let tagged = self.config.categories.values().flatten().any(|identity| {
video.channel_id.as_deref().is_some_and(|id| {
identity.strip_prefix("https://www.youtube.com/channel/") == Some(id)
}) || video.uploader_id.as_deref().is_some_and(|handle| {
identity.strip_prefix("https://www.youtube.com/") == Some(handle)
|| (video.channel_id.is_none()
&& self.config.channels.get(handle) == Some(identity))
})
});
let channel = Cell::from(video.channel.as_deref().unwrap_or("Unknown"));
Row::new([
Cell::from(video.title.as_str()),
if tagged {
channel.fg(Color::Cyan)
} else {
channel
},
Cell::from(video.publication_date.as_deref().unwrap_or("Unknown")),
])
}),
[
Constraint::Fill(3),
Constraint::Fill(1),
Constraint::Length(11),
],
)
.header(Row::new(["Video", "Channel", "Published"]).bold())
.block(Block::bordered().title(loading_title(
if view.source == ViewSource::Search {
" Results "
} else {
view.title.as_str()
},
"",
self.job.is_some().then_some(spinner),
)))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
frame.render_stateful_widget(table, results, &mut view.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
.block(Block::bordered().title(" Status ")),
status,
);
let help_text = self.key_hints.footer(self.key_context());
frame.render_widget(Paragraph::new(help_text).dim(), help);
if let Some(mode) = self.preview
&& 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 block = Block::bordered().border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(popup);
let cached = cached_description(&self.descriptions, &video.id);
let text = match cached {
Some("") => "This video has no description.",
Some(description) => description,
None => self
.description_error
.as_deref()
.unwrap_or("Loading full description..."),
};
let mut details = if mode == PreviewMode::Description {
inner.inner(ratatui::layout::Margin::new(1, 0))
} else {
let [_, image, _, mut details] = Layout::horizontal([
Constraint::Length(1),
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);
details
};
let metadata = [
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(),
];
if mode == PreviewMode::Description {
let mut content = Text::from(text);
content.lines.splice(
0..0,
[Line::default()]
.into_iter()
.chain(metadata)
.chain([Line::default()]),
);
let paragraph = Paragraph::new(content).wrap(Wrap { trim: false });
let viewport = (details.width, details.height);
if self.description_viewport != Some(viewport) {
self.description_max_scroll = if details.is_empty() {
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)), details);
} else {
let metadata = Paragraph::new(Vec::from(metadata)).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());
}
}
frame.render_widget(
block.title(loading_title(
if mode == PreviewMode::Info {
" Info "
} else {
" Description "
},
if mode == PreviewMode::Info {
self.key_hints.preview[0].as_str()
} else {
self.key_hints.preview[1].as_str()
},
(self.description_job.is_some()
|| (mode == PreviewMode::Info && self.thumbnail.is_loading()))
.then_some(spinner),
)),
popup,
);
}
self.draw_category_dialog(frame);
self.draw_browser(frame);
self.draw_help(frame);
}
fn draw_help(&mut self, frame: &mut Frame) {
let Some(help) = &mut self.help_popup else {
return;
};
let popup = preview_area(frame.area());
let block = Block::bordered()
.title(self.key_hints.help.as_str())
.border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(popup).inner(ratatui::layout::Margin::new(1, 0));
let viewport = (inner.width, inner.height);
if help.viewport != Some(viewport) {
help.max_scroll = if inner.is_empty() {
0
} else {
help.paragraph
.line_count(inner.width)
.saturating_sub(inner.height as usize)
.min(u16::MAX as usize) as u16
};
help.viewport = Some(viewport);
}
help.scroll = help.scroll.min(help.max_scroll);
frame.render_widget(Clear, popup);
help.paragraph = std::mem::take(&mut help.paragraph).scroll((help.scroll, 0));
frame.render_widget(&help.paragraph, inner);
frame.render_widget(block, popup);
}
fn draw_browser(&mut self, frame: &mut Frame) {
let Some(browser) = &mut self.browser else {
return;
};
let popup = preview_area(frame.area());
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(match browser.kind {
BrowserKind::Categories => self.key_hints.browser[0].as_str(),
BrowserKind::Channels => self.key_hints.browser[1].as_str(),
})
.border_style(Style::new().fg(Color::Cyan));
let [input, list, error] = Layout::vertical([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(if browser.error.is_some() { 2 } else { 0 }),
])
.areas(block.inner(popup));
frame.render_widget(block, popup);
let input_block = Block::bordered().title(if browser.mode == Mode::Insert {
" Filter · INSERT "
} else {
" Filter · NORMAL "
});
let text_area = input_block.inner(input);
let text = Line::raw(browser.query.as_str());
let scroll = text
.width()
.saturating_sub(text_area.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(input_block),
input,
);
if browser.mode == Mode::Insert && text_area.width != 0 && text_area.height != 0 {
frame.set_cursor_position((text_area.x + cursor.min(text_area.width - 1), text_area.y));
}
if browser.choices.is_empty() {
frame.render_widget(
Paragraph::new(match browser.kind {
BrowserKind::Categories => {
"No categories yet. Use s on a video to save its channel."
}
BrowserKind::Channels => {
"No saved channels yet. Use s on a video to save its channel."
}
})
.wrap(Wrap { trim: true })
.dim(),
list,
);
} else if browser.matches.is_empty() {
frame.render_widget(
Paragraph::new(match browser.kind {
BrowserKind::Categories => "No matching categories.",
BrowserKind::Channels => "No matching channels.",
})
.dim(),
list,
);
} else {
let items = browser.matches.iter().map(|index| {
let (name, source) = &browser.choices[*index];
let mut line = Line::default();
if let ViewSource::Category(category) = source {
line.spans.push(
if self.config.pinned_categories.contains(category) {
"[p] "
} else {
"[ ] "
}
.into(),
);
}
line.spans.push(name.as_str().into());
line
});
frame.render_stateful_widget(
List::new(items)
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
list,
&mut browser.selection,
);
}
if let Some(message) = browser.error.as_deref() {
frame.render_widget(
Paragraph::new(message)
.wrap(Wrap { trim: true })
.style(Style::new().fg(Color::Red)),
error,
);
}
}
fn draw_category_dialog(&mut self, frame: &mut Frame) {
let Some(dialog) = &mut self.category_dialog else {
return;
};
let area = frame.area();
let bulk = matches!(&dialog.target, CategoryTarget::Category { .. });
let width = area.width.saturating_sub(4).min(60);
let height = area.height.saturating_sub(4).min(if dialog.name.is_some() {
7
} else {
dialog
.tags
.len()
.saturating_add(if bulk { 10 } else { 8 })
.clamp(10, if bulk { 21 } else { 19 }) as u16
});
let popup = Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
);
frame.render_widget(Clear, popup);
let block = Block::bordered()
.title(if dialog.name.is_some() {
" New tag "
} else if bulk {
" Bulk tags "
} else {
" Tags "
})
.title_bottom(dialog.label.as_str())
.border_style(Style::new().fg(Color::Cyan));
let [input, body, warning, error, help] = Layout::vertical([
Constraint::Length(if dialog.name.is_none() { 3 } else { 0 }),
Constraint::Min(1),
Constraint::Length(if bulk && dialog.name.is_none() { 2 } else { 0 }),
Constraint::Length(if dialog.error.is_some() { 2 } else { 0 }),
Constraint::Length(1),
])
.areas(block.inner(popup));
frame.render_widget(block, popup);
if dialog.name.is_none() {
let input_block = Block::bordered().title(if dialog.mode == Mode::Insert {
" Filter · INSERT "
} else {
" Filter · NORMAL "
});
let text_area = input_block.inner(input);
let text = Line::raw(dialog.query.as_str());
let scroll = text
.width()
.saturating_sub(text_area.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text)
.scroll((0, scroll.min(u16::MAX as usize) as u16))
.block(input_block),
input,
);
if dialog.mode == Mode::Insert && text_area.width != 0 && text_area.height != 0 {
frame.set_cursor_position((
text_area.x + cursor.min(text_area.width - 1),
text_area.y,
));
}
}
if bulk && dialog.name.is_none() {
frame.render_widget(Paragraph::new(
"Saving replaces all memberships. Only tags shared by every channel start checked."
).wrap(Wrap { trim: true }).dim(), warning);
}
if let Some(name) = &dialog.name {
let text = Line::raw(name.as_str());
let scroll = text
.width()
.saturating_sub(body.width.saturating_sub(1) as usize);
let cursor = text.width().saturating_sub(scroll) as u16;
frame.render_widget(
Paragraph::new(text).scroll((0, scroll.min(u16::MAX as usize) as u16)),
body,
);
if body.width != 0 && body.height != 0 {
frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y));
}
} else if dialog.tags.is_empty() {
frame.render_widget(Paragraph::new(self.key_hints.tags_empty.as_str()), body);
} else if dialog.matches.is_empty() {
frame.render_widget(Paragraph::new("No matching tags.").dim(), body);
} else {
frame.render_stateful_widget(
List::new(
dialog
.tags
.iter()
.enumerate()
.filter(|(index, _)| dialog.matches.binary_search(index).is_ok())
.map(|(_, (name, checked))| {
Line::from(vec![
(if *checked { "[x] " } else { "[ ] " }).into(),
name.as_str().into(),
])
}),
)
.highlight_symbol("> ")
.highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)),
body,
&mut dialog.selection,
);
}
if let Some(message) = &dialog.error {
frame.render_widget(
Paragraph::new(message.as_str())
.wrap(Wrap { trim: true })
.fg(Color::Red),
error,
);
}
frame.render_widget(
Paragraph::new(if dialog.name.is_some() {
self.key_hints.tags[1].as_str()
} else if dialog.mode == Mode::Insert {
self.key_hints.tags[2].as_str()
} else {
self.key_hints.tags[0].as_str()
})
.dim(),
help,
);
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
struct ResetCursorStyle;
impl Drop for ResetCursorStyle {
fn drop(&mut self) {
let _ = execute!(io::stdout(), SetCursorStyle::DefaultUserShape);
}
}
execute!(io::stdout(), SetCursorStyle::SteadyBlock)?;
let _cursor_style = ResetCursorStyle;
self.thumbnail.query_terminal();
if !self.query.trim().is_empty() {
self.search();
}
let started = Instant::now();
loop {
self.update();
terminal.draw(|frame| self.draw(frame, started.elapsed()))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& key.kind != KeyEventKind::Release
&& self.key(key)
{
return Ok(());
}
}
}
}
fn loading_title<'a>(title: &'a str, controls: &'a str, spinner: Option<&'static str>) -> Line<'a> {
let mut spans =
Vec::with_capacity(1 + usize::from(!controls.is_empty()) + usize::from(spinner.is_some()));
spans.push(title.into());
if let Some(spinner) = spinner {
spans.push(spinner.into());
}
if !controls.is_empty() {
spans.push(controls.into());
}
Line::from(spans)
}
fn preview_area(area: Rect) -> Rect {
let width = area
.width
.saturating_sub(4)
.min((u32::from(area.width) * 70 / 100) as u16)
.min(96);
let height = area
.height
.saturating_sub(4)
.min((u32::from(area.height) * 60 / 100) as u16)
.min(20);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
fn edit_input(text: &mut String, key: KeyEvent, action: Option<Action>) {
match action {
Some(Action::DeleteLine) => text.clear(),
Some(Action::DeleteWord) => {
let keep = text
.char_indices()
.rev()
.skip_while(|(_, c)| c.is_whitespace())
.find(|(_, c)| c.is_whitespace())
.map_or(0, |(index, c)| index + c.len_utf8());
text.truncate(keep);
}
Some(Action::DeleteCharacter) => {
text.pop();
}
None => {
if let KeyCode::Char(c) = key.code
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
text.push(c);
}
}
_ => {}
}
}
fn main() -> io::Result<()> {
let options = Options::parse();
if options.default_config {
return std::io::Write::write_all(
&mut io::stdout().lock(),
keys::default_config().as_bytes(),
);
}
let config_path = config::config_path()?;
let config = config::load(&config_path)?;
let app = App::new(options, config_path, config);
ratatui::run(|terminal| app.run(terminal))
}
#[cfg(test)]
mod tests {
use super::*;
fn app(args: &[&str]) -> App {
App::new(
Options::parse_from(args),
PathBuf::new(),
config::Config::default(),
)
}
fn loaded_channel_views(app: &mut App) {
let videos = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
for (label, identity) in app.config.saved_channels() {
let mut view = View::new(label, ViewSource::Channel(identity));
view.videos = youtube::feed_results(videos).unwrap().videos;
view.loaded = true;
view.next_start = 41;
view.selection.select(Some(1));
app.views.push(view);
}
}
fn browse_channel(app: &mut App, filter: &str) {
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
for c in filter.chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
}
#[test]
fn video_channel_colors_follow_category_membership_and_preserve_selection_contrast() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
let tagged_id = "UC4QobU6STFB0P71PMvOGN5A";
let registered_id = "UCaYhcUwRBNscFNUKTjgPFiA";
app.config.channels.insert(
"@mapped".into(),
config::normalize_channel(tagged_id).unwrap(),
);
app.config.channels.insert(
"@registered".into(),
config::normalize_channel(registered_id).unwrap(),
);
app.config.categories.insert(
"Favorites".into(),
vec![
config::normalize_channel(tagged_id).unwrap(),
config::normalize_channel("@direct").unwrap(),
],
);
let cases = [
("TaggedID", Some(tagged_id), None, true),
("TaggedHandle", None, Some("@direct"), true),
("MappedHandle", None, Some("@mapped"), true),
(
"Registered",
Some(registered_id),
Some("@registered"),
false,
),
("TaggedID", Some(registered_id), None, false),
("Unknown", None, None, false),
];
app.views[0].videos = youtube::search_results(
&serde_json::json!({
"entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({
"id": "jNQXAC9IVRw", "title": "Video title", "channel": channel,
"channel_id": id, "uploader_id": handle, "upload_date": "20261011"
})).collect::<Vec<_>>()
})
.to_string(),
)
.unwrap()
.videos;
app.views[0].selection.select(None);
let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap();
for cleared in [false, true] {
if cleared {
app.config.categories.clear();
}
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let buffer = terminal.backend().buffer();
// One tab row, three search rows, then the table border and header.
for (index, (label, _, _, tagged)) in cases.iter().enumerate() {
let y = 6 + index as u16;
let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect();
let x = row.find(label).expect("channel label is visible") as u16;
assert_eq!(
buffer[(x, y)].fg,
if *tagged && !cleared {
Color::Cyan
} else {
Color::Reset
},
"{label} in row {index}, cleared={cleared}"
);
assert_eq!(buffer[(3, y)].fg, Color::Reset, "title stays uncolored");
assert_eq!(buffer[(108, y)].fg, Color::Reset, "date stays uncolored");
}
if !cleared {
app.views[0].selection.select(Some(0));
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let buffer = terminal.backend().buffer();
let row: String = (0..120).map(|x| buffer[(x, 6)].symbol()).collect();
let x = row.find("TaggedID").unwrap() as u16;
assert_eq!(buffer[(x, 6)].fg, Color::Black);
assert_eq!(buffer[(x, 6)].bg, Color::Cyan);
app.views[0].selection.select(None);
}
}
}
#[test]
fn remapped_preview_and_input_actions_survive_key_only_config_reload() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
).unwrap().videos;
// Invalid IDs avoid curl; full descriptions exercise the real preview without extraction.
for (index, id) in ["first", "second"].into_iter().enumerate() {
app.views[0].videos[index].id = id.into();
app.remember_description(id.into(), "A long description paragraph.\n\n".repeat(40));
}
app.views[0].loaded = true;
app.views[0].selection.select(Some(0));
app.views[0].next_start = 41;
let document = r#"[keys]
video.info.overview = "I"
video.info.hide = "x"
search.edit = "f2"
input.delete.word = "ctrl+backspace"
input.delete.line = "ctrl+l"
"#
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.apply_config(config::Config {
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
..Default::default()
});
let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers));
press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE);
assert_eq!(app.preview, Some(PreviewMode::Description));
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL);
assert_eq!(app.description_scroll, 1);
press(&mut app, KeyCode::Char('I'), KeyModifiers::SHIFT);
assert_eq!(app.preview, Some(PreviewMode::Info));
press(&mut app, KeyCode::Char('x'), KeyModifiers::NONE);
assert_eq!(app.preview, None);
assert!(app.description_job.is_none());
let remap = "[keys]\nvideo.select.next = 'n'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.apply_config(config::Config {
keys: keys::Bindings::from_toml(remap.get("keys")).unwrap(),
..Default::default()
});
assert_eq!(app.views[0].next_start, 41);
assert_eq!(app.views[0].videos[0].title, "First");
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
assert_eq!(app.views[0].selection.selected(), Some(0));
press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE);
assert_eq!(app.views[0].selection.selected(), Some(1));
app.apply_config(config::Config {
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
..Default::default()
});
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
press(&mut app, KeyCode::F(2), KeyModifiers::NONE);
for character in "red blue I d?".chars() {
press(&mut app, KeyCode::Char(character), KeyModifiers::NONE);
}
assert_eq!(app.query, "red blue I d?");
assert_eq!(app.preview, None);
press(&mut app, KeyCode::Backspace, KeyModifiers::CONTROL);
assert_eq!(app.query, "red blue I ");
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
assert_eq!(app.query, "red blue I ");
press(&mut app, KeyCode::Char('l'), KeyModifiers::CONTROL);
assert_eq!(app.query, "");
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn remapped_selector_and_tag_keys_keep_input_and_modal_navigation_separate() {
let mut app = app(&["tubular"]);
let document = r#"[keys]
selector.select.next = "l"
selector.edit = "f3"
selector.close = "z"
input.submit = "ctrl+e"
input.cancel = "ctrl+b"
tags.new = "f4"
tags.cancel = "z"
"#
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.apply_config(config::Config {
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
..Default::default()
});
loaded_channel_views(&mut app);
let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers));
app.open_browser(BrowserKind::Channels);
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE);
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
press(&mut app, KeyCode::F(3), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE);
assert_eq!(app.browser.as_ref().unwrap().query, "l");
assert_eq!(app.browser.as_ref().unwrap().matches, [0]);
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL);
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(app.browser.is_some());
press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL);
assert!(app.browser.is_none());
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
assert_eq!(app.views[app.view_index].next_start, 41);
let history = app.view_history.clone();
app.category_dialog = Some(CategoryDialog {
name: None,
..CategoryDialog::new(
CategoryTarget::Channel {
handle: "@Beta".into(),
channel: "beta".into(),
},
"@Beta".into(),
BTreeMap::new(),
)
});
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(app.view_history, history);
assert!(app.category_dialog.is_some());
press(&mut app, KeyCode::F(4), KeyModifiers::NONE);
for character in "lI?".chars() {
press(&mut app, KeyCode::Char(character), KeyModifiers::NONE);
}
press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL);
assert_eq!(
app.category_dialog.as_ref().unwrap().tags.get("lI?"),
Some(&true)
);
press(&mut app, KeyCode::Char('z'), KeyModifiers::NONE);
assert!(app.category_dialog.is_none());
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
}
#[test]
fn help_scrolls_without_navigating_and_closes_to_the_same_preview() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
..Default::default()
});
loaded_channel_views(&mut app);
browse_channel(&mut app, "Alpha");
browse_channel(&mut app, "Beta");
let history = app.view_history.clone();
let selected = app.views[app.view_index].selection.selected();
let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE));
app.views[app.view_index].has_more = true;
assert!(app.can_prefetch());
press(&mut app, KeyCode::Char('?'));
assert!(!app.can_prefetch());
app.preview = Some(PreviewMode::Info);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
press(&mut app, KeyCode::Char('j'));
assert_eq!(app.help_popup.as_ref().unwrap().scroll, 1);
for code in [
KeyCode::Enter,
KeyCode::Tab,
KeyCode::Char('-'),
KeyCode::Char('+'),
] {
press(&mut app, code);
}
assert_eq!(app.view_history, history);
assert!(app.forward_history.is_empty());
assert!(app.player_launch.is_none());
assert_eq!(app.views[app.view_index].selection.selected(), selected);
for _ in 0..100 {
press(&mut app, KeyCode::PageDown);
}
let help = app.help_popup.as_ref().unwrap();
assert_eq!(help.scroll, help.max_scroll);
let previous_max = help.max_scroll;
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert_eq!(app.help_popup.as_ref().unwrap().scroll, previous_max - 1);
let mut larger = Terminal::new(TestBackend::new(120, 40)).unwrap();
larger
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let help = app.help_popup.as_ref().unwrap();
assert_eq!(help.scroll, help.max_scroll);
for _ in 0..100 {
press(&mut app, KeyCode::PageUp);
}
assert_eq!(app.help_popup.as_ref().unwrap().scroll, 0);
press(&mut app, KeyCode::Esc);
assert!(app.help_popup.is_none());
assert_eq!(app.preview, Some(PreviewMode::Info));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
press(&mut app, KeyCode::Char('-'));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("alpha".into())
);
press(&mut app, KeyCode::Char('+'));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
}
#[test]
fn help_preserves_selector_state_and_question_mark_is_literal_in_every_input() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [("@Alpha".into(), "alpha".into())].into(),
..Default::default()
});
let press = |app: &mut App, code| app.key(KeyEvent::new(code, KeyModifiers::NONE));
app.open_browser(BrowserKind::Channels);
press(&mut app, KeyCode::Char('?'));
assert!(app.help_popup.is_some());
press(&mut app, KeyCode::Char('?'));
assert!(app.help_popup.is_none());
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
press(&mut app, KeyCode::Char('i'));
press(&mut app, KeyCode::Char('?'));
assert!(app.help_popup.is_none());
assert_eq!(app.browser.as_ref().unwrap().query, "?");
assert!(app.browser.as_ref().unwrap().matches.is_empty());
press(&mut app, KeyCode::Esc);
press(&mut app, KeyCode::Esc);
press(&mut app, KeyCode::Char('i'));
press(&mut app, KeyCode::Char('?'));
assert!(app.help_popup.is_none());
assert_eq!(app.query, "?");
press(&mut app, KeyCode::Esc);
app.category_dialog = Some(CategoryDialog {
name: Some(String::new()),
..CategoryDialog::new(
CategoryTarget::Channel {
handle: "@Alpha".into(),
channel: "alpha".into(),
},
"@Alpha".into(),
BTreeMap::new(),
)
});
press(&mut app, KeyCode::Char('?'));
assert!(app.help_popup.is_none());
assert_eq!(
app.category_dialog.as_ref().unwrap().name.as_deref(),
Some("?")
);
press(&mut app, KeyCode::Esc);
press(&mut app, KeyCode::Char('?'));
assert!(app.help_popup.is_some());
press(&mut app, KeyCode::Esc);
assert!(app.category_dialog.is_some());
assert!(app.help_popup.is_none());
}
#[test]
fn plus_restores_forward_views_and_new_navigation_discards_the_forward_branch() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
..Default::default()
});
loaded_channel_views(&mut app);
for view in &mut app.views {
view.loaded = true;
}
browse_channel(&mut app, "Alpha");
browse_channel(&mut app, "Beta");
let press = |app: &mut App, c| {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
};
press(&mut app, '-');
press(&mut app, '-');
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
press(&mut app, '+');
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("alpha".into())
);
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
assert_eq!(app.views[app.view_index].next_start, 41);
app.switch_view(app.view_index); // Refocusing does not discard forward history.
press(&mut app, '+');
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
press(&mut app, '+'); // End of history is a no-op.
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
press(&mut app, '-');
app.switch_view(1);
press(&mut app, '+');
assert_eq!(app.views[app.view_index].source, ViewSource::Feed);
app.mode = Mode::Insert;
press(&mut app, '+');
assert_eq!(app.query, "+");
assert_eq!(app.views[app.view_index].source, ViewSource::Feed);
assert!(app.job.is_none());
}
#[test]
fn forward_navigation_skips_unsubscribed_views() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
..Default::default()
});
loaded_channel_views(&mut app);
app.views[0].loaded = true;
browse_channel(&mut app, "Alpha");
browse_channel(&mut app, "Beta");
app.back_view();
app.back_view();
app.apply_config(config::Config {
channels: [("@Beta".into(), "beta".into())].into(),
..Default::default()
});
app.key(KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
assert_eq!(app.temporary_view, Some(ViewSource::Channel("beta".into())));
assert!(app.job.is_none());
}
#[test]
fn escape_and_minus_restore_view_history_without_consuming_modal_escape() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
categories: [("Category".into(), Vec::new())].into(),
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
..Default::default()
});
loaded_channel_views(&mut app);
for view in &mut app.views {
view.loaded = true;
}
app.switch_view(2);
browse_channel(&mut app, "Alpha");
browse_channel(&mut app, "Beta");
app.switch_view(app.view_index); // Refocusing must not add a history entry.
app.preview = Some(PreviewMode::Info);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.preview.is_none());
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
app.mode = Mode::Insert;
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.query, "-");
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
app.open_browser(BrowserKind::Channels);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.browser.is_none());
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("beta".into())
);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("alpha".into())
);
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
assert_eq!(app.views[app.view_index].next_start, 41);
assert_eq!(
app.temporary_view,
Some(ViewSource::Channel("alpha".into()))
);
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Category("Category".into())
);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
assert!(app.job.is_none());
}
#[test]
fn back_history_uses_source_identity_after_config_reorders_or_removes_views() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
categories: [("Earlier".into(), Vec::new()), ("Keep".into(), Vec::new())].into(),
channels: [("@Alpha".into(), "alpha".into())].into(),
..Default::default()
});
loaded_channel_views(&mut app);
for view in &mut app.views {
view.loaded = true;
}
app.switch_view(3); // Keep initially follows Earlier.
browse_channel(&mut app, "Alpha");
app.apply_config(config::Config {
categories: [("Keep".into(), Vec::new())].into(),
channels: [("@Alpha".into(), "alpha".into())].into(),
..Default::default()
});
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Category("Keep".into())
);
browse_channel(&mut app, "Alpha");
app.apply_config(config::Config {
channels: [("@Alpha".into(), "alpha".into())].into(),
..Default::default()
});
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
assert!(app.job.is_none());
}
#[test]
fn video_channel_shortcut_reuses_saved_views_and_requires_valid_identity() {
let mut app = app(&["tubular"]);
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
app.apply_config(config::Config {
channels: [("@Saved".into(), identity.into())].into(),
..Default::default()
});
loaded_channel_views(&mut app);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Saved"}]}"#,
).unwrap().videos;
app.views[0].loaded = true;
app.views[0].selection.select(Some(0));
app.preview = Some(PreviewMode::Description);
let press = |app: &mut App| {
app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
};
press(&mut app);
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel(identity.into())
);
assert_eq!(app.views[app.view_index].selection.selected(), Some(1));
assert_eq!(app.views[app.view_index].next_start, 41);
assert_eq!(app.views[app.view_index].videos[1].title, "Second");
assert!(app.preview.is_none());
assert!(app.job.is_none());
let count = app.views.len();
press(&mut app);
assert_eq!(app.views.len(), count);
app.switch_view(0);
app.views[0].videos[0].channel_id = None;
press(&mut app); // A handle-only row resolves through the saved mapping.
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel(identity.into())
);
app.switch_view(0);
app.views[0].videos[0].uploader_id = Some("@invalid/handle".into());
press(&mut app);
assert_eq!(app.view_index, 0);
assert_eq!(app.views.len(), count);
assert!(app.job.is_none());
app.views[0].videos[0].uploader_id = None;
press(&mut app);
assert_eq!(app.view_index, 0);
app.mode = Mode::Insert;
press(&mut app);
assert_eq!(app.query, "U");
assert_eq!(app.view_index, 0);
}
#[test]
fn unsaved_video_channels_survive_config_changes_without_subscribing() {
let mut app = app(&["tubular"]);
let identity = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
let mut channel = View::new("@Unsaved".into(), ViewSource::Channel(identity.into()));
channel.loaded = true;
app.views.push(channel);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Search result","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@Unsaved"}]}"#,
).unwrap().videos;
app.views[0].selection.select(Some(0));
app.views[0].loaded = true;
app.key(KeyEvent::new(KeyCode::Char('U'), KeyModifiers::SHIFT));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel(identity.into())
);
assert!(app.config.subscriptions().is_empty());
app.apply_config(config::Config {
categories: [("Unrelated".into(), Vec::new())].into(),
..Default::default()
});
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel(identity.into())
);
assert!(app.views[app.view_index].loaded);
assert_eq!(
app.temporary_view,
Some(ViewSource::Channel(identity.into()))
);
app.apply_config(config::Config {
channels: [("@Registered".into(), identity.into())].into(),
..Default::default()
});
assert_eq!(app.views[app.view_index].name, "@Registered");
assert!(app.views[app.view_index].loaded);
app.apply_config(config::Config::default());
assert_eq!(app.view_index, 0);
assert!(
app.views
.iter()
.all(|view| view.source != ViewSource::Channel(identity.into()))
);
}
#[test]
fn channels_filter_in_both_modes_without_pinning_or_running_modal_shortcuts() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [
("@jazz".into(), "jazz identity".into()),
("@jokes".into(), "jokes identity".into()),
]
.into(),
..config::Config::default()
});
loaded_channel_views(&mut app);
app.query = "untouched".into();
app.views[0].loaded = true;
app.views[0].has_more = true;
app.preview = Some(PreviewMode::Info);
assert!(!app.can_prefetch());
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
assert!(app.preview.is_none());
assert!(!app.can_prefetch());
app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
assert!(app.config.pinned_categories.is_empty());
assert!(app.browser.as_ref().unwrap().error.is_none());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
for c in "jkp".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert_eq!(app.browser.as_ref().unwrap().query, "jkp");
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None);
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.view_index, 0);
assert!(app.browser.is_some());
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.key(KeyEvent::new(KeyCode::Char('J'), KeyModifiers::NONE));
for (code, modifiers, row) in [
(KeyCode::Char('j'), KeyModifiers::CONTROL, 1),
(KeyCode::Char('k'), KeyModifiers::CONTROL, 0),
(KeyCode::Down, KeyModifiers::NONE, 1),
(KeyCode::Up, KeyModifiers::NONE, 0),
] {
app.key(KeyEvent::new(code, modifiers));
assert_eq!(
app.browser.as_ref().unwrap().selection.selected(),
Some(row)
);
}
assert_eq!(app.browser.as_ref().unwrap().query, "J");
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal);
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel("jazz identity".into())
);
assert_eq!(
app.views[app.view_index]
.source
.channels(&app.config, &app.feed_channels),
["jazz identity"]
);
assert_eq!(app.query, "untouched");
assert!(app.browser.is_none());
assert!(app.job.is_none());
}
#[test]
fn channel_and_category_share_temporary_tab_and_cached_history() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
keys: Default::default(),
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
categories: [
("@Alpha".into(), vec!["beta".into()]),
("Temporary".into(), vec![]),
]
.into(),
pinned_categories: vec!["@Alpha".into()],
});
loaded_channel_views(&mut app);
app.views[2].loaded = true;
app.switch_view(3);
browse_channel(&mut app, "Alpha");
let alpha_source = ViewSource::Channel("alpha".into());
assert_eq!(app.views[app.view_index].source, alpha_source);
assert_eq!(
app.views[app.view_index]
.source
.channels(&app.config, &app.feed_channels),
["alpha"]
);
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
assert!(app.category_dialog.is_none());
assert_eq!(app.views[app.view_index].source, alpha_source);
browse_channel(&mut app, "Beta");
let visible: Vec<_> = app
.visible_views()
.map(|(_, view)| view.source.clone())
.collect();
assert_eq!(
visible,
[
ViewSource::Search,
ViewSource::Feed,
ViewSource::Category("@Alpha".into()),
ViewSource::Channel("beta".into())
]
);
assert!(app.views.iter().any(|view| view.source == alpha_source));
browse_channel(&mut app, "Alpha");
let view = &app.views[app.view_index];
assert_eq!(view.source, alpha_source);
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(view.next_start, 41);
assert_eq!(view.videos.len(), 2);
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('-'), KeyModifiers::NONE));
assert_eq!(
app.views[app.view_index].source,
ViewSource::Category("Temporary".into())
);
assert_eq!(app.visible_views().count(), 4);
assert!(
!app.visible_views()
.any(|(_, view)| matches!(view.source, ViewSource::Channel(_)))
);
browse_channel(&mut app, "Alpha");
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.views[app.view_index].source, alpha_source);
assert!(app.job.is_none());
}
#[test]
fn channel_refresh_matches_identities_relabels_caches_and_removes_unsubscribed_views() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [
("@Alpha".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
categories: [("@Alpha".into(), vec!["beta".into()])].into(),
..config::Config::default()
});
loaded_channel_views(&mut app);
browse_channel(&mut app, "Alpha");
app.views[app.view_index].has_more = true;
app.views[app.view_index].pending =
youtube::feed_results(r#"{"entries":[{"id":"l-GaaivVJCE","title":"Pending"}]}"#)
.unwrap()
.videos;
app.views[app.view_index].page_error = Some("kept".into());
app.apply_config(config::Config {
keys: Default::default(),
channels: [
("@Renamed".into(), "alpha".into()),
("@Beta".into(), "beta".into()),
]
.into(),
categories: [
("New tag".into(), vec!["alpha".into()]),
("@Alpha".into(), vec![]),
]
.into(),
pinned_categories: vec!["@Alpha".into()],
});
let view = &app.views[app.view_index];
assert_eq!(view.source, ViewSource::Channel("alpha".into()));
assert_eq!(view.name, "@Renamed");
assert_eq!(view.title, " @Renamed ");
assert!(view.loaded && view.has_more);
assert_eq!(view.next_start, 41);
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(view.pending.len(), 1);
assert_eq!(view.page_error.as_deref(), Some("kept"));
let beta = app
.views
.iter()
.find(|view| view.source == ViewSource::Channel("beta".into()))
.unwrap();
assert_eq!(beta.next_start, 41);
assert_eq!(beta.selection.selected(), Some(1));
app.apply_config(config::Config {
keys: Default::default(),
channels: [("@Beta".into(), "beta".into())].into(),
categories: [("@Alpha".into(), vec!["beta".into()])].into(),
pinned_categories: vec!["@Alpha".into()],
});
assert_eq!(app.views[app.view_index].source, ViewSource::Search);
assert!(app.temporary_view.is_none());
assert!(
!app.views
.iter()
.any(|view| view.source == ViewSource::Channel("alpha".into()))
);
browse_channel(&mut app, "Beta");
assert_eq!(app.views[app.view_index].next_start, 41);
app.apply_config(config::Config::default());
assert_eq!(app.view_index, 0);
assert_eq!(app.views.len(), 2);
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
for code in [KeyCode::Down, KeyCode::Enter, KeyCode::Char('p')] {
app.key(KeyEvent::new(code, KeyModifiers::NONE));
}
assert!(app.browser.as_ref().unwrap().choices.is_empty());
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), None);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.browser.is_none());
assert!(app.job.is_none());
}
#[test]
fn channel_labels_cannot_alias_builtin_or_category_sources() {
let mut app = app(&["tubular"]);
// Raw fixture identities exercise equal labels without invoking an extractor.
app.apply_config(config::Config {
categories: [
("Feed".into(), vec!["Feed".into(), "Search".into()]),
("Search".into(), vec![]),
]
.into(),
pinned_categories: vec!["Feed".into(), "Search".into()],
..config::Config::default()
});
loaded_channel_views(&mut app);
for label in ["Feed", "Search"] {
browse_channel(&mut app, label);
assert_eq!(
app.views[app.view_index].source,
ViewSource::Channel(label.into())
);
assert_eq!(
app.views[app.view_index]
.source
.channels(&app.config, &app.feed_channels),
[label]
);
assert_eq!(
app.visible_views()
.filter(|(_, view)| view.name == label)
.count(),
3
);
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
assert!(app.category_dialog.is_none());
}
assert!(app.job.is_none());
}
#[test]
fn channel_selector_renders_plain_labels_modes_empty_states_and_filter_cursor() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
channels: [("@Alpha".into(), "alpha".into())].into(),
..config::Config::default()
});
let mut terminal = Terminal::new(TestBackend::new(180, 40)).unwrap();
let text = |terminal: &Terminal<TestBackend>| -> String {
terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect()
};
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let rendered = text(&terminal);
assert!(rendered.contains("@Alpha"));
assert!(!rendered.contains("[p]") && !rendered.contains("[ ]"));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE));
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let popup = preview_area(Rect::new(0, 0, 180, 40));
assert_eq!(
terminal.get_cursor_position().unwrap(),
(popup.x + 3, popup.y + 2).into()
);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
app.apply_config(config::Config::default());
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::NONE));
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
assert!(app.job.is_none());
}
#[test]
fn filtered_tagging_saves_visible_changes_without_losing_hidden_memberships() {
use ratatui::{Terminal, backend::TestBackend};
for bulk in [false, true] {
let path = std::env::temp_dir().join(format!(
"tubular-filtered-tags-{}-{bulk}.toml",
std::process::id()
));
let original = "[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@a', '@b']\nGamma = []\nZebra = ['@a', '@b']\n";
std::fs::write(&path, original).unwrap();
let mut app = App::new(
Options::parse_from(["tubular"]),
path.clone(),
config::load(&path).unwrap(),
);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Creator video","channel":"Creator","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@a"}]}"#
).unwrap().videos;
app.views[0].selection.select(Some(0));
if bulk {
app.view_index = app
.views
.iter()
.position(|view| view.name == "Alpha")
.unwrap();
}
app.views[app.view_index].loaded = true;
let press = |app: &mut App, code, modifiers| {
app.key(KeyEvent::new(code, modifiers));
};
press(
&mut app,
KeyCode::Char(if bulk { 'S' } else { 's' }),
KeyModifiers::NONE,
);
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
for c in "missing".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
let mut terminal = Terminal::new(TestBackend::new(120, 30)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("No matching tags."));
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
for c in "GA".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::SHIFT);
}
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(app.category_dialog.is_some(), "filter Enter must not save");
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(text.contains("[x] Gamma"));
assert!(!text.contains("[x] Alpha") && !text.contains("[x] Zebra"));
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
assert!(app.category_dialog.is_none());
let saved = config::load(&path).unwrap();
let a = config::normalize_channel("UC4QobU6STFB0P71PMvOGN5A").unwrap();
let b = config::normalize_channel("UCaYhcUwRBNscFNUKTjgPFiA").unwrap();
assert_eq!(saved.categories["Alpha"], [a.clone(), b.clone()]);
assert_eq!(saved.categories["Zebra"], [a.clone(), b.clone()]);
assert_eq!(
saved.categories["Gamma"],
if bulk { vec![a, b] } else { vec![a] }
);
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn tag_filter_preserves_selection_and_stages_new_tags_outside_the_filter() {
let mut app = app(&["tubular"]);
app.category_dialog = Some(CategoryDialog::new(
CategoryTarget::Channel {
handle: "@a".into(),
channel: String::new(),
},
"Creator".into(),
[
("Alpha".into(), false),
("Gamma".into(), true),
("Zebra".into(), false),
]
.into(),
));
let press = |app: &mut App, code, modifiers| {
app.key(KeyEvent::new(code, modifiers));
};
press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('a'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(app.category_dialog.as_ref().unwrap().tags["Zebra"]);
assert!(!app.category_dialog.as_ref().unwrap().tags["Alpha"]);
press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE);
press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL);
for c in "gamma".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE);
for c in "New".chars() {
press(&mut app, KeyCode::Char(c), KeyModifiers::NONE);
}
press(&mut app, KeyCode::Enter, KeyModifiers::NONE);
let dialog = app.category_dialog.as_ref().unwrap();
assert!(dialog.tags["New"]);
assert!(dialog.query.is_empty(), "new tag must be visible");
press(&mut app, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(
!app.category_dialog.as_ref().unwrap().tags["New"],
"new tag is selected"
);
press(&mut app, KeyCode::Esc, KeyModifiers::NONE);
assert!(app.category_dialog.is_none());
assert!(
app.config.categories.is_empty(),
"cancel discards staged changes"
);
}
#[test]
fn bulk_picker_uses_all_saved_channels_and_commits_or_cancels_the_whole_group() {
let path =
std::env::temp_dir().join(format!("tubular-bulk-picker-{}.toml", std::process::id()));
let original = "[settings]\npinned_categories = ['Source']\n[channels]\n'@a' = 'UC4QobU6STFB0P71PMvOGN5A'\n'@b' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n'@other' = 'UCBR8-60-B28hp2BmDPdntcQ'\n[categories]\nEmpty = []\nMixed = ['@a', '@other']\nShared = ['@a', '@b', '@other']\nSource = ['@a', '@b']\n";
std::fs::write(&path, original).unwrap();
let mut app = App::new(
Options::parse_from(["tubular"]),
path.clone(),
config::load(&path).unwrap(),
);
loaded_channel_views(&mut app);
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
assert!(app.category_dialog.is_none());
app.view_index = app
.views
.iter()
.position(|view| view.name == "Source")
.unwrap();
app.views[app.view_index].loaded = true;
app.views[1].loaded = true;
app.views[1].next_start = 41;
let subscriptions = app.feed_channels.clone();
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
let dialog = app.category_dialog.as_ref().unwrap();
assert!(dialog.tags["Shared"] && dialog.tags["Source"]);
assert!(!dialog.tags["Mixed"] && !dialog.tags["Empty"]);
assert!(
matches!(&dialog.target, CategoryTarget::Category { channels, .. } if channels.len() == 2)
);
assert!(app.views[app.view_index].videos.is_empty());
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
for c in "Staged".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
assert!(!app.config.categories.contains_key("Staged"));
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
let saved = config::load(&path).unwrap();
assert!(saved.categories["Source"].is_empty());
assert_eq!(
saved.categories["Mixed"],
["https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ"]
);
assert_eq!(saved.categories["Shared"].len(), 3);
assert_eq!(saved.pinned_categories, ["Source"]);
assert_eq!(app.feed_channels, subscriptions);
assert!(app.views[1].loaded);
assert_eq!(app.views[1].next_start, 41);
browse_channel(&mut app, "@a");
let channel_source = app.views[app.view_index].source.clone();
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
let channel = &app.views[app.view_index];
assert_eq!(channel.source, channel_source);
assert!(channel.loaded);
assert_eq!(channel.videos.len(), 2);
assert_eq!(channel.selection.selected(), Some(1));
assert_eq!(channel.next_start, 41);
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Char('S'), KeyModifiers::SHIFT));
assert!(app.category_dialog.is_none());
std::fs::remove_file(path).unwrap();
}
#[test]
fn filtering_preserves_selected_categories_and_browses_the_filtered_row() {
let mut app = app(&["tubular"]);
app.query = "main search untouched".into();
app.apply_config(config::Config {
categories: [
("Alpha".into(), vec![]),
("Japanese immersion".into(), vec![]),
("Japanese news".into(), vec![]),
]
.into(),
..config::Config::default()
});
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
for c in "JAPANESE".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
for c in " news".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.browser.as_ref().unwrap().mode, Mode::Normal);
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].name, "Japanese news");
assert_eq!(app.query, "main search untouched");
assert!(app.browser.is_none());
}
#[test]
fn insert_filter_types_jk_handles_empty_matches_and_navigates_without_editing() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
categories: [
("Alpha".into(), vec![]),
("Jazz".into(), vec![]),
("Jokes".into(), vec![]),
("日本語だけで".into(), vec![]),
]
.into(),
..config::Config::default()
});
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
for c in "jk".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
assert!(app.browser.as_ref().unwrap().selection.selected().is_none());
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.browser.is_some());
assert_eq!(app.view_index, 0);
app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
for (code, modifiers, row) in [
(KeyCode::Char('j'), KeyModifiers::CONTROL, 1),
(KeyCode::Char('k'), KeyModifiers::CONTROL, 0),
(KeyCode::Down, KeyModifiers::NONE, 1),
(KeyCode::Up, KeyModifiers::NONE, 0),
] {
app.key(KeyEvent::new(code, modifiers));
assert_eq!(
app.browser.as_ref().unwrap().selection.selected(),
Some(row)
);
}
assert_eq!(app.browser.as_ref().unwrap().query, "j");
app.key(KeyEvent::new(KeyCode::Char('w'), KeyModifiers::CONTROL));
for c in "日本".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].name, "日本語だけで");
assert_eq!(app.mode, Mode::Normal);
}
#[test]
fn category_browser_replaces_the_temporary_tab_and_cycles_only_visible_views() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
categories: [
("Alpha".into(), vec![]),
("Beta".into(), vec![]),
("Gamma".into(), vec![]),
]
.into(),
..config::Config::default()
});
let visible = |app: &App| {
app.visible_views()
.map(|(_, view)| view.name.clone())
.collect::<Vec<_>>()
};
assert_eq!(visible(&app), ["Search", "Feed"]);
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.view_index, 0);
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.views[app.view_index].name, "Beta");
assert_eq!(visible(&app), ["Search", "Feed", "Beta"]);
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(visible(&app), ["Search", "Feed", "Gamma"]);
app.apply_config(config::Config {
categories: app.config.categories.clone(),
pinned_categories: vec!["Beta".into()],
..config::Config::default()
});
assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Gamma"]);
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.view_index, 0);
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.views[app.view_index].name, "Gamma");
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.views[app.view_index].name, "Beta");
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(visible(&app), ["Search", "Feed", "Beta"]);
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(visible(&app), ["Search", "Feed", "Beta"]);
assert!(app.browser.is_none());
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
}
#[test]
fn categories_named_search_or_feed_are_distinct_from_permanent_views() {
let mut app = app(&["tubular"]);
app.apply_config(config::Config {
categories: [
("Alpha".into(), vec![]),
("Feed".into(), vec![]),
("Search".into(), vec![]),
]
.into(),
..config::Config::default()
});
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.view_index, 3);
assert_eq!(app.views[app.view_index].name, "Feed");
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1));
}
#[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, style::Modifier};
let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Heading","channel":"Creator","upload_date":"20050423"}]}"#,
).unwrap().videos;
app.views[0].selection.select(Some(0));
app.preview = Some(PreviewMode::Info);
for (last_line, hidden) in [
("line", false),
("line", true),
("abcdefghijklmnopqrs", true),
("a界界界界界界界界界", true),
("", true),
] {
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))
.unwrap();
let buffer = terminal.backend().buffer();
let popup = preview_area(buffer.area);
let text_x = (popup.x + 1..popup.right() - 1)
.find(|&x| {
let cell = &buffer[(x, popup.y + 1)];
cell.symbol() == "H" && cell.modifier.contains(Modifier::BOLD)
})
.expect("title is visible");
let row: String = (text_x..popup.right() - 3)
.map(|x| buffer[(x, popup.bottom() - 2)].symbol())
.collect();
if hidden {
assert!(row.trim_end().ends_with('…'), "{row}");
if last_line.starts_with("a界") {
assert!(row.starts_with("a界"));
for x in text_x..popup.right() - 3 {
if buffer[(x, popup.bottom() - 2)].symbol() == "界" {
assert_eq!(buffer[(x + 1, popup.bottom() - 2)].symbol(), " ");
}
}
}
} else {
assert_eq!(row.trim_end(), "line");
}
}
}
#[test]
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.remember_description(
"jNQXAC9IVRw".into(),
format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)),
);
app.views[0].selection.select(Some(0));
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
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let buffer = terminal.backend().buffer();
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 text_x = (popup.x + 1..popup.right() - 1)
.find(|&x| {
let cell = &buffer[(x, popup.y + 1)];
cell.symbol() == "F" && cell.modifier.contains(Modifier::BOLD)
})
.expect("title is visible");
let title = &buffer[(text_x, popup.y + 1)];
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[(text_x, excerpt_y)];
assert!(!excerpt.modifier.contains(Modifier::DIM));
assert!(!excerpt.modifier.contains(Modifier::BOLD));
}
}
#[test]
fn view_tabs_wrap_whole_labels_and_keep_the_selected_row_visible() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.views = ["Search", "Feed", "Language immersion", "日本語だけ", "中文"]
.into_iter()
.enumerate()
.map(|(index, name)| {
View::new(
name.into(),
match index {
0 => ViewSource::Search,
1 => ViewSource::Feed,
_ => ViewSource::Category(name.into()),
},
)
})
.collect();
app.config.pinned_categories = app
.views
.iter()
.skip(2)
.map(|view| view.name.clone())
.collect();
app.view_index = 4;
app.mode = Mode::Insert;
for (width, height) in [(30, 20), (56, 20), (55, 20), (9, 20), (30, 12)] {
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let tab_height = terminal.get_cursor_position().unwrap().y - 1;
let buffer = terminal.backend().buffer();
let locate = |symbol: &str| {
(0..tab_height)
.flat_map(|y| (0..width).map(move |x| (x, y)))
.find(|&position| buffer.cell(position).unwrap().symbol() == symbol)
};
let chinese = locate("中").expect("selected category remains visible");
assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan);
assert_eq!(
buffer.cell((chinese.0 + 2, chinese.1)).unwrap().symbol(),
"文"
);
if width >= 16 {
let japanese = locate("日").expect("neighboring category remains visible");
for (offset, character) in "日本語だけ".chars().enumerate() {
assert_eq!(
buffer
.cell((japanese.0 + offset as u16 * 2, japanese.1))
.unwrap()
.symbol(),
character.to_string(),
"category label must not split across rows"
);
}
assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan);
}
if width >= 24 {
assert!(
(0..tab_height).any(|y| {
(0..width)
.map(|x| buffer.cell((x, y)).unwrap().symbol())
.collect::<String>()
.contains("Language immersion")
}),
"whole category label must fit after wrapping"
);
}
}
}
#[test]
fn search_pages_preserve_relevance_and_selection() {
let mut view = View::new("Search".into(), ViewSource::Search);
let mut first = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#,
)
.unwrap();
first.has_more = true;
view.accept_page(first, false).unwrap();
view.selection.select(Some(1));
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}]}"#,
)
.unwrap();
next.has_more = true;
view.accept_page(next, false).unwrap();
assert_eq!(
view.videos
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["jNQXAC9IVRw", "rQ_J9WH6CGk", "l-GaaivVJCE", "aaaaaaaaaaa"]
);
assert_eq!(view.videos[1].title, "Second");
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(view.next_start, 41);
assert!(view.has_more);
}
#[test]
fn feed_pages_hold_short_channel_tails_until_newer_long_histories_finish() {
let mut view = View::new("Feed".into(), ViewSource::Feed);
let long_page = |base: i64| {
serde_json::json!({
"entries": (0..PAGE_SIZE).map(|index| serde_json::json!({
"id": format!("long{:07}", base + index as i64),
"title": "Long channel",
"timestamp": base + index as i64,
})).collect::<Vec<_>>()
})
.to_string()
};
let short = r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Short old tail","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Unknown date"}]}"#;
let first = youtube::feed_results(&format!("{}\n{short}", long_page(200))).unwrap();
view.accept_page(first, true).unwrap();
assert_eq!(view.videos.len(), PAGE_SIZE);
assert_eq!(view.videos.last().unwrap().timestamp, Some(200));
assert_eq!(view.pending.len(), 2);
view.selection.select(Some(PAGE_SIZE - 1));
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));
view.accept_page(
youtube::feed_results(
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Long last row","timestamp":179}]} {"entries":[]}"#,
).unwrap(),
true,
).unwrap();
assert_eq!(
view.videos[PAGE_SIZE * 2..]
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["l-GaaivVJCE", "jNQXAC9IVRw", "rQ_J9WH6CGk"]
);
assert!(view.pending.is_empty());
assert!(!view.has_more);
assert!(!view.needs_page());
}
#[test]
fn unavailable_full_channel_pages_do_not_release_other_channels_ahead_of_newer_uploads() {
let mut view = View::new("Feed".into(), ViewSource::Feed);
let dated = |base: i64| {
serde_json::json!({
"entries": (0..PAGE_SIZE).map(|index| serde_json::json!({
"id": format!("dated{:06}", base + index as i64),
"title": "Dated upload",
"timestamp": base + index as i64,
})).collect::<Vec<_>>()
})
};
let unavailable = serde_json::json!({
"entries": vec![serde_json::Value::Null; PAGE_SIZE]
});
let page = youtube::feed_results(&format!("{}\n{unavailable}", dated(100))).unwrap();
view.accept_page(page, true).unwrap();
assert!(view.videos.is_empty());
assert_eq!(view.selection.selected(), None);
assert!(view.needs_page());
let page = youtube::feed_results(&format!("{{\"entries\":[]}}\n{}", dated(200))).unwrap();
view.accept_page(page, true).unwrap();
assert_eq!(view.videos.first().unwrap().timestamp, Some(219));
assert_eq!(view.videos.last().unwrap().timestamp, Some(200));
view.accept_page(youtube::feed_results(r#"{"entries":[]}"#).unwrap(), true)
.unwrap();
assert_eq!(view.videos[PAGE_SIZE].timestamp, Some(119));
assert_eq!(view.videos.last().unwrap().timestamp, Some(100));
assert!(!view.has_more);
}
#[test]
fn undated_full_feed_pages_stay_pending_and_do_not_look_exhausted() {
let mut app = app(&["tubular", "rust"]);
app.view_index = 1;
app.feed_channels
.push("https://www.youtube.com/@jawed".into());
app.config
.channels
.insert("@jawed".into(), app.feed_channels[0].clone());
let entries: Vec<_> = (0..PAGE_SIZE)
.map(|index| {
serde_json::json!({
"id": format!("unknown{index:04}"),
"title": "Undated",
})
})
.collect();
app.finish_page(Ok(serde_json::json!({"entries": entries}).to_string()));
assert!(app.views[1].videos.is_empty());
assert_eq!(app.views[1].pending.len(), PAGE_SIZE);
assert_eq!(app.views[1].next_start, 21);
assert!(app.views[1].loaded);
assert!(app.can_prefetch());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.views[1].selection.selected(), None);
assert!(app.job.is_none());
app.finish_page(Err("Continuation failed".into()));
assert_eq!(app.views[1].pending.len(), PAGE_SIZE);
assert_eq!(app.views[1].next_start, 21);
assert!(!app.can_prefetch());
app.finish_page(Ok(r#"{"entries":[]}"#.into()));
assert_eq!(app.views[1].videos.len(), PAGE_SIZE);
assert!(app.views[1].pending.is_empty());
assert_eq!(app.views[1].selection.selected(), Some(0));
assert!(!app.can_prefetch());
app.views[1].next_start = 81;
app.views[1].has_more = true;
app.views[1].page_error = Some("Refresh resets this".into());
app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE));
assert_eq!(app.views[1].next_start, 1);
assert!(app.views[1].videos.is_empty());
assert!(app.views[1].pending.is_empty());
assert!(!app.views[1].has_more);
assert!(app.views[1].page_error.is_none());
assert!(app.job.is_none());
}
#[test]
fn page_failures_and_view_switches_preserve_the_successful_cursor_until_reset() {
let mut app = app(&["tubular", "rust"]);
app.submitted_query = "rust".into();
app.finish_page(Err("First request failed".into()));
assert_eq!(app.views[0].next_start, 1);
assert!(!app.views[0].loaded);
assert!(app.views[0].page_error.is_some());
app.switch_view(0);
app.update();
assert!(app.job.is_none());
app.views[0].reset();
let entries: Vec<_> = (0..PAGE_SIZE)
.map(|index| {
serde_json::json!({
"id": format!("search{index:05}"),
"title": "Result",
})
})
.collect();
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.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);
assert_eq!(app.views[0].selection.selected(), Some(17));
app.finish_page(Ok("not JSON".into()));
app.update();
assert!(app.job.is_none());
assert_eq!(app.views[0].videos.len(), PAGE_SIZE);
assert_eq!(
cached_description(&app.descriptions, &app.views[0].videos[17].id),
Some("Cached.")
);
assert_eq!(app.views[0].next_start, 21);
assert!(!app.views[0].needs_page());
let view = &mut app.views[0];
view.pending =
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Pending"}]}"#)
.unwrap()
.videos;
view.reset();
assert!(view.videos.is_empty());
assert!(view.pending.is_empty());
assert_eq!(view.selection.selected(), None);
assert_eq!(view.next_start, 1);
assert!(!view.loaded);
assert!(!view.has_more);
assert!(view.page_error.is_none());
}
#[test]
fn prefetch_only_runs_near_the_end_in_an_unobstructed_normal_view() {
let mut app = app(&["tubular"]);
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let mut page =
youtube::search_results(r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Result"}]}"#)
.unwrap();
page.has_more = true;
app.views[0].accept_page(page, false).unwrap();
assert!(!app.can_prefetch());
app.mode = Mode::Normal;
assert!(app.can_prefetch());
app.preview = Some(PreviewMode::Description);
assert!(!app.can_prefetch());
app.preview = Some(PreviewMode::Info);
assert!(!app.can_prefetch());
app.preview = None;
app.category_dialog = Some(CategoryDialog {
name: None,
..CategoryDialog::new(
CategoryTarget::Channel {
channel: String::new(),
handle: String::new(),
},
String::new(),
BTreeMap::new(),
)
});
assert!(!app.can_prefetch());
app.category_dialog = None;
let entries: Vec<_> = (0..PAGE_SIZE)
.map(|index| {
serde_json::json!({
"id": format!("search{index:05}"),
"title": "Next result",
})
})
.collect();
app.views[0]
.accept_page(
youtube::search_results(&serde_json::json!({"entries": entries}).to_string())
.unwrap(),
false,
)
.unwrap();
assert_eq!(app.views[0].selection.selected(), Some(0));
assert!(!app.can_prefetch());
app.views[0].selection.select(Some(15));
assert!(!app.can_prefetch());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert!(app.can_prefetch());
assert!(app.job.is_none());
app.views[0].page_error = Some("Stop retrying".into());
assert!(!app.can_prefetch());
app.views[0].page_error = None;
app.views[0].videos.clear();
app.views[0].selection.select(None);
assert!(app.can_prefetch());
}
#[test]
fn input_cursor_tracks_display_width_and_stays_inside_scrolled_fields() {
use ratatui::{Terminal, backend::TestBackend};
let mut app = app(&["tubular"]);
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
let mut terminal = Terminal::new(TestBackend::new(24, 20)).unwrap();
for (text, x) in [
("", 1),
("ab", 3),
("日本語", 7),
("e\u{301}", 2),
("abcdefghijklmnopqrstuvwxyz", 22),
] {
app.query = text.into();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), (x, 2).into());
}
app.category_dialog = Some(CategoryDialog {
name: Some(String::new()),
..CategoryDialog::new(
CategoryTarget::Channel {
channel: String::new(),
handle: String::new(),
},
String::new(),
BTreeMap::new(),
)
});
for (text, x) in [("", 3), ("日本語", 9), ("abcdefghijklmnopqrstuvwxyz", 20)] {
app.category_dialog.as_mut().unwrap().name = Some(text.into());
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), (x, 7).into());
}
}
#[test]
fn deletion_shortcuts_edit_search_and_tag_names_without_submitting() {
for tag_input in [false, true] {
for (input, code, modifiers, expected) in [
(
"first 日本語 ",
KeyCode::Backspace,
KeyModifiers::ALT,
"first ",
),
(
"first\u{3000}cafe\u{301} ",
KeyCode::Char('w'),
KeyModifiers::CONTROL,
"first\u{3000}",
),
("word", KeyCode::Backspace, KeyModifiers::ALT, ""),
(" \u{3000} ", KeyCode::Backspace, KeyModifiers::ALT, ""),
("", KeyCode::Backspace, KeyModifiers::ALT, ""),
("first 日本語", KeyCode::Backspace, KeyModifiers::SUPER, ""),
(
"first 日本語",
KeyCode::Backspace,
KeyModifiers::SUPER | KeyModifiers::ALT,
"",
),
(
"first 日本語",
KeyCode::Char('u'),
KeyModifiers::CONTROL,
"",
),
(
"first 日本語",
KeyCode::Backspace,
KeyModifiers::NONE,
"first 日本",
),
] {
let mut app = app(&["tubular"]);
if tag_input {
app.mode = Mode::Normal;
app.category_dialog = Some(CategoryDialog {
name: Some(input.into()),
..CategoryDialog::new(
CategoryTarget::Channel {
channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"
.into(),
handle: "@jawed".into(),
},
"jawed".into(),
BTreeMap::new(),
)
});
} else {
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.query = input.into();
}
app.key(KeyEvent::new(code, modifiers));
let edited = app
.category_dialog
.as_ref()
.and_then(|dialog| dialog.name.as_deref())
.unwrap_or(&app.query);
assert_eq!(
edited, expected,
"{tag_input}: {input:?} {code:?} {modifiers:?}"
);
assert!(app.job.is_none());
assert_eq!(
app.mode,
if tag_input {
Mode::Normal
} else {
Mode::Insert
}
);
}
}
let mut app = app(&["tubular"]);
app.query = "keep this query".into();
app.mode = Mode::Normal;
for (code, modifiers) in [
(KeyCode::Backspace, KeyModifiers::ALT),
(KeyCode::Backspace, KeyModifiers::SUPER),
(KeyCode::Char('w'), KeyModifiers::CONTROL),
] {
app.key(KeyEvent::new(code, modifiers));
assert_eq!(app.query, "keep this query");
}
}
#[test]
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
let mut app = app(&["tubular"]);
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.views[0].videos = vec![
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: None,
channel_id: None,
uploader_id: None,
timestamp: None,
publication_date: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
title: "Rust course".into(),
channel: None,
channel_id: None,
uploader_id: None,
timestamp: None,
publication_date: None,
},
];
app.views[0].selection.select(Some(0));
for c in ['j', 'k', 'q'] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
app.update();
assert_eq!(app.query, "jkq");
assert_eq!(app.views[0].selection.selected(), Some(0));
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.views[0].selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Insert);
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(app.mode, Mode::Normal);
assert!(app.query.is_empty());
assert!(app.job.is_none());
}
#[test]
fn category_dialog_is_modal_and_views_keep_independent_selections() {
let mut app = app(&["tubular", "rust"]);
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel":"Rust","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
app.views[0].videos = youtube::search_results(videos).unwrap().videos;
app.views[0].loaded = true;
app.views[0].selection.select(Some(1));
// The selected channel is unavailable; do not save a guessed display-name identity.
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
app.views[0].selection.select(Some(0));
let handle = app.views[0].videos[0].uploader_id.take();
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
app.views[0].videos[0].uploader_id = handle;
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
for c in ['j', 'k', 'q', 'i', 't', 'd', ' '] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
assert_eq!(
app.category_dialog.as_ref().unwrap().name.as_deref(),
Some("jkqitd ")
);
assert_eq!(app.query, "rust");
assert_eq!(app.views[0].selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('A'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().name.is_none());
assert!(app.category_dialog.as_ref().unwrap().tags["A"]);
assert!(app.config.categories.is_empty());
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(!app.category_dialog.as_ref().unwrap().tags["A"]);
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(!app.category_dialog.as_ref().unwrap().tags["A"]);
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
assert_eq!(app.mode, Mode::Normal);
assert!(app.config.categories.is_empty());
let rust = vec!["https://www.youtube.com/@RustVideos".into()];
app.apply_config(config::Config {
keys: Default::default(),
categories: [("Rust".into(), rust.clone())].into(),
channels: BTreeMap::new(),
pinned_categories: vec!["Rust".into()],
});
app.views[0].selection.select(Some(1));
app.views[1].videos = youtube::feed_results(videos).unwrap().videos;
app.views[1].loaded = true;
app.views[1].selection.select(Some(0));
app.views[2].videos = youtube::feed_results(videos).unwrap().videos;
app.views[2].loaded = true;
app.views[2].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.view_index, 1);
assert_eq!(app.views[1].name, "Feed");
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.view_index, 2);
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.views[2].selection.selected(), Some(1));
assert_eq!(app.views[1].selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.view_index, 1);
assert_eq!(app.views[1].selection.selected(), Some(0));
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.view_index, 0);
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.apply_config(config::Config {
keys: Default::default(),
categories: [
(
"Books".into(),
vec!["https://www.youtube.com/@example".into()],
),
("Rust".into(), rust),
]
.into(),
channels: BTreeMap::new(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 3);
assert_eq!(app.views[3].name, "Rust");
assert_eq!(app.views[3].selection.selected(), Some(1));
assert!(app.views[3].loaded);
app.apply_config(config::Config {
keys: Default::default(),
categories: [("Rust".into(), vec![])].into(),
channels: BTreeMap::new(),
pinned_categories: Vec::new(),
});
assert_eq!(app.views[app.view_index].name, "Rust");
assert!(app.views[app.view_index].videos.is_empty());
assert!(app.views[app.view_index].selection.selected().is_none());
assert!(!app.views[app.view_index].loaded);
assert!(app.job.is_none());
}
#[test]
fn feed_cache_tracks_subscriptions_not_tag_memberships() {
let registered = "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA";
let direct = "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A";
let added = "https://www.youtube.com/channel/UCBR8-60-B28hp2BmDPdntcQ";
let mut app = App::new(
Options::parse_from(["tubular", "rust"]),
PathBuf::new(),
config::Config {
keys: Default::default(),
channels: [("@RustVideos".into(), registered.into())].into(),
categories: [
("Direct".into(), vec![direct.into()]),
("Tagged".into(), vec![registered.into()]),
]
.into(),
pinned_categories: Vec::new(),
},
);
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#;
assert_eq!(app.feed_channels, vec![direct, registered]);
app.views[1].videos = youtube::feed_results(videos).unwrap().videos;
app.views[1].selection.select(Some(1));
app.views[1].loaded = true;
app.views[1].next_start = 41;
app.views[1].has_more = true;
app.views[1].pending = youtube::feed_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Buffered","timestamp":50}]}"#,
)
.unwrap()
.videos;
app.views[1].page_error = Some("Retained page error".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;
app.views[3].next_start = 61;
app.views[3].has_more = true;
app.views[3].pending = youtube::feed_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Tag pending","timestamp":50}]}"#,
)
.unwrap()
.videos;
app.views[3].page_error = Some("Tag page failed".into());
app.switch_view(1);
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [
("Direct".into(), vec![direct.into()]),
("Renamed".into(), vec![registered.into()]),
("Tagged".into(), vec![]),
]
.into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 1);
assert!(app.views[1].loaded);
assert_eq!(app.views[1].videos[1].id, "rQ_J9WH6CGk");
assert_eq!(app.views[1].selection.selected(), Some(1));
assert_eq!(app.views[1].next_start, 41);
assert!(app.views[1].has_more);
assert_eq!(app.views[1].pending[0].id, "jNQXAC9IVRw");
assert_eq!(
app.views[1].page_error.as_deref(),
Some("Retained page error")
);
assert_eq!(
cached_description(&app.descriptions, &app.views[1].videos[1].id),
Some("Retained description")
);
assert!(!app.views[4].loaded);
assert!(app.views[4].videos.is_empty());
assert_eq!(app.views[4].selection.selected(), None);
assert_eq!(app.views[4].next_start, 1);
assert!(!app.views[4].has_more);
assert!(app.views[4].pending.is_empty());
assert!(app.views[4].page_error.is_none());
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [
("Direct".into(), vec![direct.into()]),
("Renamed".into(), vec![]),
("Tagged".into(), vec![]),
]
.into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.feed_channels, vec![direct, registered]);
assert!(app.views[1].loaded);
assert_eq!(app.views[1].videos.len(), 2);
assert_eq!(app.views[1].selection.selected(), Some(1));
assert_eq!(app.views[1].next_start, 41);
assert!(app.views[1].has_more);
assert_eq!(app.views[1].pending.len(), 1);
assert!(app.views[1].page_error.is_some());
let mut channels = app.config.channels.clone();
channels.insert("@YouTube".into(), added.into());
app.apply_config(config::Config {
keys: Default::default(),
channels,
categories: app.config.categories.clone(),
pinned_categories: Vec::new(),
});
assert_eq!(app.feed_channels, vec![direct, added, registered]);
assert!(!app.views[1].loaded);
assert!(app.views[1].videos.is_empty());
assert_eq!(app.views[1].selection.selected(), None);
assert_eq!(app.views[1].next_start, 1);
assert!(!app.views[1].has_more);
assert!(app.views[1].pending.is_empty());
assert!(app.views[1].page_error.is_none());
assert_eq!(app.view_index, 1);
assert!(app.job.is_none());
app.views[1].videos = youtube::feed_results(videos).unwrap().videos;
app.views[1].selection.select(Some(1));
app.views[1].loaded = true;
let mut channels = app.config.channels.clone();
channels.remove("@YouTube");
app.apply_config(config::Config {
keys: Default::default(),
channels,
categories: app.config.categories.clone(),
pinned_categories: Vec::new(),
});
assert_eq!(app.feed_channels, vec![direct, registered]);
assert!(!app.views[1].loaded);
assert!(app.views[1].videos.is_empty());
assert_eq!(app.views[1].selection.selected(), None);
assert_eq!(app.view_index, 1);
assert!(app.job.is_none());
}
#[test]
fn builtins_and_same_named_tags_keep_distinct_identities() {
let mut app = app(&["tubular", "rust"]);
app.apply_config(config::Config {
keys: Default::default(),
channels: [(
"@RustVideos".into(),
"https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(),
)]
.into(),
categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(),
pinned_categories: Vec::new(),
});
let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#;
for (index, view) in app.views.iter_mut().enumerate() {
view.videos = youtube::feed_results(videos).unwrap().videos;
view.loaded = true;
view.selection.select(Some(usize::from(index % 2 == 0)));
}
app.switch_view(1);
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [
("Alpha".into(), vec![]),
("Feed".into(), vec![]),
("Search".into(), vec![]),
]
.into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 1);
assert_eq!(app.views[1].selection.selected(), Some(0));
assert_eq!(app.views[3].name, "Feed");
assert_eq!(app.views[3].selection.selected(), Some(1));
assert_eq!(app.views[0].selection.selected(), Some(1));
assert_eq!(app.views[4].selection.selected(), Some(0));
app.switch_view(0);
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 0);
app.switch_view(2);
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [
("Alpha".into(), vec![]),
("Feed".into(), vec![]),
("Search".into(), vec![]),
]
.into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 3);
assert_eq!(app.views[3].selection.selected(), Some(1));
app.switch_view(4);
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [
("Alpha".into(), vec![]),
("Beta".into(), vec![]),
("Feed".into(), vec![]),
("Search".into(), vec![]),
]
.into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 5);
assert_eq!(app.views[5].selection.selected(), Some(0));
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [
("Alpha".into(), vec![]),
("Beta".into(), vec![]),
("Feed".into(), vec![]),
]
.into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 0);
assert_eq!(app.views[0].selection.selected(), Some(1));
app.switch_view(4);
app.apply_config(config::Config {
keys: Default::default(),
channels: app.config.channels.clone(),
categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(),
pinned_categories: Vec::new(),
});
assert_eq!(app.view_index, 0);
assert_eq!(app.views[1].name, "Feed");
assert_eq!(app.views[1].selection.selected(), Some(0));
assert!(app.job.is_none());
}
#[test]
fn empty_feed_is_informative_and_navigation_wraps_without_extraction() {
let mut app = app(&["tubular"]);
assert_eq!(app.views.len(), 2);
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::CONTROL));
assert_eq!(app.view_index, 1);
assert!(app.views[1].loaded);
assert!(app.views[1].videos.is_empty());
assert_eq!(app.views[1].selection.selected(), None);
assert!(app.job.is_none());
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(app.view_index, 0);
app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT));
assert_eq!(app.view_index, 1);
assert!(app.job.is_none());
}
#[test]
fn channel_tags_stage_memberships_and_commit_only_from_the_list() {
let path = std::env::temp_dir().join(format!(
"tubular-tag-editor-test-{}.toml",
std::process::id()
));
struct TestFile(PathBuf);
impl Drop for TestFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.unwrap();
let _file = TestFile(path.clone());
let original = "[categories]\nAlias = [\"@RustVideos\"]\nCanonical = [\"UCaYhcUwRBNscFNUKTjgPFiA\"]\nEmpty = []\n";
std::fs::write(&path, original).unwrap();
let mut app = app(&["tubular", "rust"]);
app.config_path = path.clone();
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Rust video","channel_id":"UCaYhcUwRBNscFNUKTjgPFiA","uploader_id":"@RustVideos"}]}"#,
)
.unwrap().videos;
app.views[0].loaded = true;
app.views[0].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert_eq!(
app.category_dialog.as_ref().unwrap().tags,
[
("Alias".into(), true),
("Canonical".into(), true),
("Empty".into(), false),
]
.into()
);
app.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().tags["Alias"]);
app.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
for c in "Empty".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().error.is_some());
app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
for c in " Added ".chars() {
app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE));
}
app.key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(
app.category_dialog.as_ref().unwrap().selection.selected(),
Some(0)
);
assert!(app.category_dialog.as_ref().unwrap().tags["Added"]);
assert_eq!(std::fs::read_to_string(&path).unwrap(), original);
app.category_dialog.as_mut().unwrap().error = Some("Previous save failed".into());
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(app.category_dialog.as_ref().unwrap().error.is_none());
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
let saved = config::load(&path).unwrap();
let canonical = vec!["https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".to_owned()];
assert!(saved.categories["Alias"].is_empty());
assert!(saved.categories["Added"].is_empty());
assert_eq!(saved.categories["Canonical"], canonical);
assert_eq!(saved.categories["Empty"], canonical);
let committed = std::fs::read_to_string(&path).unwrap();
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
assert_eq!(std::fs::read_to_string(&path).unwrap(), committed);
std::fs::write(&path, "[categories]\n").unwrap();
app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE));
assert!(
app.category_dialog
.as_ref()
.unwrap()
.selection
.selected()
.is_none()
);
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
assert!(config::load(&path).unwrap().categories.is_empty());
}
#[test]
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":"First"},{"id":"9bZkp7q19f0","title":"Second"}]}"#,
).unwrap().videos;
// 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;
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());
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 title = &buffer[(popup.x + 2, popup.y + 2)];
assert_eq!(title.symbol(), "S");
assert!(title.modifier.contains(ratatui::style::Modifier::BOLD));
press(&mut app, KeyCode::Down);
assert_eq!(app.description_scroll, 1);
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert_eq!(app.description_scroll, 2);
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert_eq!(app.description_scroll, 1);
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL));
assert_eq!(app.description_scroll, 0);
app.description_scroll = app.description_max_scroll;
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
assert_eq!(app.description_scroll, app.description_max_scroll);
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);
}
#[test]
fn full_description_scrolls_padded_metadata_and_body_together() {
use ratatui::{Terminal, backend::TestBackend, style::Modifier};
let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Selected video","channel":"Creator","upload_date":"20050423"}]}"#,
).unwrap().videos;
app.views[0].selection.select(Some(0));
app.preview = Some(PreviewMode::Description);
app.remember_description(
"jNQXAC9IVRw".into(),
(0..100).map(|i| format!("Body line {i:03}\n")).collect(),
);
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let before = terminal.backend().buffer().clone();
let popup = preview_area(before.area);
let x = popup.x + 2;
let title_y = popup.y + 2;
let body_y = (title_y..popup.bottom() - 1)
.find(|&y| before[(x, y)].symbol() == "B")
.expect("description is visible below metadata");
for x in popup.x + 1..popup.right() - 1 {
assert_eq!(before[(x, popup.y + 1)].symbol(), " ");
}
assert!(before[(x, title_y)].modifier.contains(Modifier::BOLD));
assert!(before[(x, title_y + 2)].modifier.contains(Modifier::DIM));
assert!(!before[(x, body_y)].modifier.contains(Modifier::DIM));
assert_eq!(
app.description_viewport,
Some((popup.width - 4, popup.height - 2))
);
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::CONTROL));
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let after = terminal.backend().buffer();
assert_eq!(after[(x, title_y - 1)].symbol(), "S");
assert!(after[(x, title_y - 1)].modifier.contains(Modifier::BOLD));
assert_eq!(after[(x, title_y + 1)].symbol(), "C");
assert!(after[(x, title_y + 1)].modifier.contains(Modifier::DIM));
let row = |buffer: &ratatui::buffer::Buffer, y| -> String {
(x..popup.right() - 2)
.map(|x| buffer[(x, y)].symbol())
.collect()
};
assert_eq!(row(&before, body_y).trim_end(), "Body line 000");
assert_eq!(row(after, body_y).trim_end(), "Body line 001");
for y in popup.y + 1..popup.bottom() - 1 {
assert_eq!(after[(popup.x + 1, y)].symbol(), " ");
assert_eq!(after[(popup.right() - 2, y)].symbol(), " ");
}
// A wrapped title cannot prevent reaching the description in a small pane.
let mut narrow = Terminal::new(TestBackend::new(12, 8)).unwrap();
narrow
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
assert!(app.description_max_scroll > 0);
app.description_scroll = app.description_max_scroll;
narrow
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let narrow_popup = preview_area(narrow.backend().buffer().area);
let narrow_buffer = narrow.backend().buffer();
let tail: String = (narrow_popup.y + 1..narrow_popup.bottom() - 1)
.flat_map(|y| {
(narrow_popup.x + 2..narrow_popup.right() - 2)
.map(move |x| narrow_buffer[(x, y)].symbol())
})
.collect();
assert!(tail.contains("099"), "{tail}");
}
}