2303 lines
97 KiB
Rust
2303 lines
97 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.
|
|
//! 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 the full description; Up/Down scrolls lines and PgUp/PgDn scrolls pages.
|
|
//! The centered popup uses up to 70% width and 60% height, capped at 96 by 20 cells.
|
|
//! Esc closes the popup; j/k loads the selected video's description and i returns to editing.
|
|
//! Normal-mode t toggles a thumbnail in the same-sized popup; j/k follows selection, Esc closes.
|
|
//! Images use native terminal graphics when supported, otherwise colored half-block characters.
|
|
//! Thumbnails scale proportionally in the left pane without cropping.
|
|
//! The right pane has a bold title above a dimmed author · publication date and description excerpt.
|
|
//! Text wraps two columns before the right border; the excerpt is clipped to the remaining height.
|
|
//! Both previews share asynchronous full-description fetching and per-video caching.
|
|
//! curl fetches JPEGs lazily into memory; decoding and image resizing/encoding run off-thread.
|
|
//! Channel tags live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
|
|
//! `[categories]` stores flat, overlapping tags: one channel can appear in several arrays.
|
|
//! 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 c edits memberships: j/k moves, Space toggles, Enter commits, Esc cancels.
|
|
//! n prompts for a new tag; Enter stages it checked, Esc returns. Only list Enter writes the file.
|
|
//! Tab/Shift-Tab switch Search, the combined Feed, then independent tag views; r reloads/refreshes.
|
|
//! 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.
|
|
//! In normal mode, approaching the loaded end fetches the next page without replacing rows/selection.
|
|
//! Loading result/preview panes animate a spinner after their title, including additional pages.
|
|
//! Thumbnail spinners cover image download/decode/resize and description fetching; errors stop them.
|
|
//! 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 `--url` and the canonical YouTube URL; it owns extraction and playback.
|
|
//! `--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 thumbnail;
|
|
mod youtube;
|
|
|
|
use std::{
|
|
collections::{BTreeMap, HashSet},
|
|
ffi::OsString,
|
|
io,
|
|
path::PathBuf,
|
|
time::{Duration, Instant},
|
|
};
|
|
|
|
use clap::Parser;
|
|
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},
|
|
widgets::{Block, 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 = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --url followed by the canonical YouTube video URL and handles extraction, decoding, and playback itself; Tubular no longer pipes media to a player.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d toggles description; t toggles thumbnail; c edits channel tags; q quits.\nTab/Shift-Tab switch Search, Feed, then tag views; r reloads config and refreshes the current view from its first page.\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[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\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, and saved registrations 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 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: 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.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, view changes, and refresh dismiss the thumbnail; typing t in INSERT or a tag name edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video reuses Terebi's window. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\""
|
|
)]
|
|
struct Options {
|
|
/// Terebi CLI executable; accepts --url followed by a YouTube video URL.
|
|
#[arg(long, default_value = "terebi")]
|
|
player: OsString,
|
|
|
|
/// Optional initial YouTube search.
|
|
query: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, PartialEq, Eq)]
|
|
enum Mode {
|
|
Insert,
|
|
Normal,
|
|
}
|
|
|
|
struct View {
|
|
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) -> Self {
|
|
Self {
|
|
title: format!(" {name} "),
|
|
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 existing rows and their description caches.
|
|
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()))
|
|
}
|
|
}
|
|
|
|
struct CategoryDialog {
|
|
channel: String,
|
|
handle: String,
|
|
label: String,
|
|
tags: BTreeMap<String, bool>,
|
|
selection: ListState,
|
|
name: Option<String>,
|
|
error: Option<String>,
|
|
}
|
|
|
|
struct App {
|
|
options: Options,
|
|
query: String,
|
|
mode: Mode,
|
|
submitted_query: String,
|
|
config_path: PathBuf,
|
|
config: config::Config,
|
|
feed_channels: Vec<String>,
|
|
views: Vec<View>,
|
|
view_index: usize,
|
|
category_dialog: Option<CategoryDialog>,
|
|
thumbnail_open: bool,
|
|
thumbnail: thumbnail::Thumbnail,
|
|
description_open: bool,
|
|
description_job: Option<(usize, Job)>,
|
|
description_error: Option<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 mut views = vec![View::new("Search".into()), View::new("Feed".into())];
|
|
views.extend(config.categories.keys().cloned().map(View::new));
|
|
Self {
|
|
options,
|
|
query,
|
|
mode: Mode::Normal,
|
|
submitted_query: String::new(),
|
|
config_path,
|
|
config,
|
|
feed_channels,
|
|
views,
|
|
view_index: 0,
|
|
category_dialog: None,
|
|
thumbnail_open: false,
|
|
thumbnail: thumbnail::Thumbnail::new(),
|
|
description_open: false,
|
|
description_job: None,
|
|
description_error: None,
|
|
description_scroll: 0,
|
|
description_max_scroll: 0,
|
|
description_viewport: None,
|
|
job: None,
|
|
player_launch: None,
|
|
status: "Press i to enter a query, then Enter to search.".into(),
|
|
}
|
|
}
|
|
|
|
fn search(&mut self) {
|
|
let query = self.query.trim();
|
|
if query.is_empty() {
|
|
self.status = "Press i to enter a query, then Enter to search.".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.job = None;
|
|
self.view_index = index;
|
|
}
|
|
self.close_thumbnail();
|
|
self.description_open = false;
|
|
self.description_job = None;
|
|
self.description_viewport = None;
|
|
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.description_open = false;
|
|
self.description_job = None;
|
|
self.close_thumbnail();
|
|
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 self.view_index == 0 {
|
|
if self.submitted_query.is_empty() {
|
|
self.status = "Press i to enter a query, then Enter to search.".into();
|
|
return;
|
|
}
|
|
self.status = format!("Searching for {} (page {page})...", self.submitted_query);
|
|
Job::search(&self.submitted_query, view.next_start)
|
|
} else {
|
|
let channels = if self.view_index == 1 {
|
|
&self.feed_channels
|
|
} else {
|
|
&self.config.categories[&view.name]
|
|
};
|
|
if channels.is_empty() {
|
|
view.loaded = true;
|
|
view.has_more = false;
|
|
self.status = format!(
|
|
"{} has no channels. Press c on a search result to add one.",
|
|
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}. Press r to refresh.",
|
|
view.videos.len(),
|
|
view.name,
|
|
(view.next_start - 1) / PAGE_SIZE + 1
|
|
);
|
|
}
|
|
if self.view_index != 0
|
|
&& (if self.view_index == 1 {
|
|
&self.feed_channels
|
|
} else {
|
|
&self.config.categories[&view.name]
|
|
})
|
|
.is_empty()
|
|
{
|
|
return format!(
|
|
"{} has no channels. Press c on a search result to add one.",
|
|
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.view_index == 0 {
|
|
youtube::search_results(&output)
|
|
} else {
|
|
youtube::feed_results(&output)
|
|
}
|
|
});
|
|
if let Err(error) = parsed
|
|
.and_then(|page| self.views[self.view_index].accept_page(page, self.view_index != 0))
|
|
{
|
|
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.description_open
|
|
&& !self.thumbnail_open
|
|
&& self.views[self.view_index].needs_page()
|
|
}
|
|
|
|
fn apply_config(&mut self, config: config::Config) {
|
|
if config.categories == self.config.categories && config.channels == self.config.channels {
|
|
return;
|
|
}
|
|
let active_index = self.view_index;
|
|
let active_name = (active_index >= 2).then(|| self.views[active_index].name.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| &view.name == name) {
|
|
old.swap_remove(index)
|
|
} else {
|
|
View::new(name.clone())
|
|
};
|
|
if config.categories.get(name) != self.config.categories.get(name) {
|
|
view.reset();
|
|
}
|
|
self.views.push(view);
|
|
}
|
|
self.view_index = if active_index < 2 {
|
|
active_index
|
|
} else {
|
|
active_name
|
|
.and_then(|name| self.views.iter().skip(2).position(|view| view.name == name))
|
|
.map(|index| index + 2)
|
|
.unwrap_or(0)
|
|
};
|
|
self.feed_channels = feed_channels;
|
|
self.config = config;
|
|
self.job = None;
|
|
self.description_open = false;
|
|
self.description_job = None;
|
|
self.close_thumbnail();
|
|
}
|
|
|
|
fn refresh(&mut self) {
|
|
self.close_thumbnail();
|
|
match config::load(&self.config_path) {
|
|
Ok(config) => {
|
|
self.apply_config(config);
|
|
self.load_view();
|
|
}
|
|
Err(error) => self.status = error.to_string(),
|
|
}
|
|
}
|
|
|
|
fn open_category_picker(&mut self) {
|
|
self.close_thumbnail();
|
|
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 mut selection = ListState::default();
|
|
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();
|
|
selection.select((!self.config.categories.is_empty()).then_some(0));
|
|
self.description_open = false;
|
|
self.description_job = None;
|
|
self.category_dialog = Some(CategoryDialog {
|
|
channel,
|
|
handle,
|
|
label,
|
|
tags,
|
|
selection,
|
|
name: None,
|
|
error: None,
|
|
});
|
|
}
|
|
|
|
fn category_key(&mut self, key: KeyEvent) {
|
|
let mut dialog = self
|
|
.category_dialog
|
|
.take()
|
|
.expect("category dialog is open");
|
|
if let Some(name) = &mut dialog.name {
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
dialog.name = None;
|
|
dialog.error = None;
|
|
}
|
|
KeyCode::Enter => {
|
|
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
|
|
.selection
|
|
.select(dialog.tags.keys().position(|tag| tag == &name));
|
|
dialog.name = None;
|
|
dialog.error = None;
|
|
}
|
|
}
|
|
_ if delete_input(name, key) => {}
|
|
KeyCode::Char(c)
|
|
if !key
|
|
.modifiers
|
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
|
{
|
|
name.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
} else {
|
|
let count = dialog.tags.len();
|
|
match key.code {
|
|
KeyCode::Esc => {
|
|
self.switch_view(self.view_index);
|
|
return;
|
|
}
|
|
KeyCode::Char('n') => {
|
|
dialog.name = Some(String::new());
|
|
dialog.error = None;
|
|
}
|
|
KeyCode::Char(' ') => {
|
|
if let Some((_, checked)) = dialog
|
|
.selection
|
|
.selected()
|
|
.and_then(|index| dialog.tags.iter_mut().nth(index))
|
|
{
|
|
*checked = !*checked;
|
|
dialog.error = None;
|
|
}
|
|
}
|
|
KeyCode::Down | KeyCode::Char('j') if count != 0 => {
|
|
dialog.selection.select(Some(
|
|
dialog
|
|
.selection
|
|
.selected()
|
|
.unwrap_or(0)
|
|
.saturating_add(1)
|
|
.min(count - 1),
|
|
));
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') if count != 0 => {
|
|
dialog.selection.select(Some(
|
|
dialog.selection.selected().unwrap_or(0).saturating_sub(1),
|
|
));
|
|
}
|
|
KeyCode::Enter => {
|
|
match config::set_channel_tags(
|
|
&self.config_path,
|
|
&dialog.tags,
|
|
&dialog.handle,
|
|
&dialog.channel,
|
|
) {
|
|
Ok(config) => {
|
|
self.apply_config(config);
|
|
self.switch_view(self.view_index);
|
|
self.status = "Saved channel tags.".into();
|
|
return;
|
|
}
|
|
Err(error) => dialog.error = Some(error.to_string()),
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
self.category_dialog = Some(dialog);
|
|
}
|
|
|
|
fn close_thumbnail(&mut self) {
|
|
if self.thumbnail_open {
|
|
self.thumbnail_open = false;
|
|
self.thumbnail.cancel();
|
|
}
|
|
}
|
|
|
|
fn load_description(&mut self) {
|
|
self.description_job = None;
|
|
self.description_error = None;
|
|
self.description_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 video.description.is_some() {
|
|
return;
|
|
}
|
|
match Job::description(&video.id) {
|
|
Ok(job) => self.description_job = Some((index, job)),
|
|
Err(error) => self.description_error = Some(error.to_string()),
|
|
}
|
|
}
|
|
|
|
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_mut(index) {
|
|
match result.and_then(|output| youtube::full_description(&output, &video.id)) {
|
|
Ok(description) => video.description = Some(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(&mut self, key: KeyEvent) -> bool {
|
|
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
|
|
return true;
|
|
}
|
|
if self.category_dialog.is_some() {
|
|
self.category_key(key);
|
|
return false;
|
|
}
|
|
let selected_before = self.views[self.view_index].selection.selected();
|
|
let description_was_open = self.description_open;
|
|
let thumbnail_was_open = self.thumbnail_open;
|
|
if self.mode == Mode::Insert {
|
|
match key.code {
|
|
KeyCode::Esc => self.mode = Mode::Normal,
|
|
KeyCode::Enter => {
|
|
self.search();
|
|
self.mode = Mode::Normal;
|
|
}
|
|
_ if delete_input(&mut self.query, key) => {}
|
|
KeyCode::Char(c)
|
|
if !key
|
|
.modifiers
|
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
|
{
|
|
self.query.push(c);
|
|
}
|
|
_ => {}
|
|
}
|
|
} else {
|
|
match key.code {
|
|
KeyCode::Char('q') => return true,
|
|
KeyCode::Char('i') => {
|
|
self.description_open = false;
|
|
self.close_thumbnail();
|
|
self.mode = Mode::Insert;
|
|
}
|
|
KeyCode::Char('d') => {
|
|
self.close_thumbnail();
|
|
self.description_open = !self.description_open
|
|
&& self.views[self.view_index]
|
|
.selection
|
|
.selected()
|
|
.is_some_and(|index| index < self.views[self.view_index].videos.len());
|
|
}
|
|
KeyCode::Char('t') => {
|
|
if self.thumbnail_open {
|
|
self.close_thumbnail();
|
|
} else {
|
|
self.thumbnail_open = self.views[self.view_index]
|
|
.selection
|
|
.selected()
|
|
.is_some_and(|index| index < self.views[self.view_index].videos.len());
|
|
}
|
|
self.description_open = false;
|
|
}
|
|
KeyCode::Esc => {
|
|
self.description_open = false;
|
|
self.close_thumbnail();
|
|
}
|
|
KeyCode::Down if self.description_open => {
|
|
self.description_scroll = self
|
|
.description_scroll
|
|
.saturating_add(1)
|
|
.min(self.description_max_scroll);
|
|
}
|
|
KeyCode::Up if self.description_open => {
|
|
self.description_scroll = self.description_scroll.saturating_sub(1);
|
|
}
|
|
KeyCode::PageDown if self.description_open => {
|
|
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);
|
|
}
|
|
KeyCode::PageUp if self.description_open => {
|
|
let page = self
|
|
.description_viewport
|
|
.map(|(_, height)| height)
|
|
.unwrap_or(1)
|
|
.max(1);
|
|
self.description_scroll = self.description_scroll.saturating_sub(page);
|
|
}
|
|
KeyCode::Char('c') => self.open_category_picker(),
|
|
KeyCode::Char('r') => self.refresh(),
|
|
KeyCode::Tab => self.switch_view((self.view_index + 1) % self.views.len()),
|
|
KeyCode::BackTab => {
|
|
self.switch_view((self.view_index + self.views.len() - 1) % self.views.len())
|
|
}
|
|
KeyCode::Enter => self.play(),
|
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
self.views[self.view_index].selection.select_next()
|
|
}
|
|
KeyCode::Up | KeyCode::Char('k') => {
|
|
self.views[self.view_index].selection.select_previous()
|
|
}
|
|
KeyCode::Home | KeyCode::Char('g') => {
|
|
self.views[self.view_index].selection.select_first()
|
|
}
|
|
KeyCode::End | KeyCode::Char('G') => {
|
|
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.description_open && !self.thumbnail_open {
|
|
self.description_job = None;
|
|
} else if (!description_was_open && !thumbnail_was_open)
|
|
|| self.views[self.view_index].selection.selected() != selected_before
|
|
{
|
|
self.load_description();
|
|
}
|
|
if self.thumbnail_open
|
|
&& (!thumbnail_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.views.iter().enumerate() {
|
|
let mut title = Span::raw(view.name.as_str());
|
|
let title_width = title.width() + 2; // One space of padding on each side.
|
|
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, " ".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| {
|
|
Row::new([
|
|
video.title.as_str(),
|
|
video.channel.as_deref().unwrap_or("Unknown"),
|
|
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 self.view_index == 0 {
|
|
" 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 = match self.mode {
|
|
Mode::Insert => {
|
|
"INSERT · Enter search → normal · Esc normal · Ctrl-U clear · Ctrl-C quit"
|
|
}
|
|
Mode::Normal if self.description_open => {
|
|
"NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit"
|
|
}
|
|
Mode::Normal if self.thumbnail_open => {
|
|
"NORMAL · j/k video · t/Esc close · d description · i insert · q quit"
|
|
}
|
|
Mode::Normal => {
|
|
"NORMAL · j/k · Enter play · i search · d info · t image · c tags · Tab views · r refresh · q quit"
|
|
}
|
|
};
|
|
frame.render_widget(Paragraph::new(help_text).dim(), help);
|
|
if self.description_open
|
|
&& let Some(video) = self.views[self.view_index]
|
|
.selection
|
|
.selected()
|
|
.and_then(|index| self.views[self.view_index].videos.get(index))
|
|
{
|
|
let popup = preview_area(frame.area());
|
|
frame.render_widget(Clear, popup);
|
|
let text = match video.description.as_deref() {
|
|
Some("") => "This video has no description.",
|
|
Some(description) => description,
|
|
None => self
|
|
.description_error
|
|
.as_deref()
|
|
.unwrap_or("Loading full description..."),
|
|
};
|
|
let paragraph = Paragraph::new(text).wrap(Wrap { trim: false });
|
|
let viewport = (
|
|
popup.width.saturating_sub(2),
|
|
popup.height.saturating_sub(2),
|
|
);
|
|
if self.description_viewport != Some(viewport) {
|
|
self.description_max_scroll = if viewport.0 == 0 || viewport.1 == 0 {
|
|
0
|
|
} else {
|
|
paragraph
|
|
.line_count(viewport.0)
|
|
.saturating_sub(viewport.1 as usize)
|
|
.min(u16::MAX as usize) as u16
|
|
};
|
|
self.description_viewport = Some(viewport);
|
|
}
|
|
self.description_scroll = self.description_scroll.min(self.description_max_scroll);
|
|
frame.render_widget(
|
|
paragraph.scroll((self.description_scroll, 0)).block(
|
|
Block::bordered()
|
|
.title(loading_title(
|
|
" Description ",
|
|
"· ↑/↓ PgUp/PgDn · d/Esc close ",
|
|
self.description_job.is_some().then_some(spinner),
|
|
))
|
|
.title_bottom(Line::from(vec![
|
|
" ".into(),
|
|
video.title.as_str().into(),
|
|
" ".into(),
|
|
]))
|
|
.border_style(Style::new().fg(Color::Cyan)),
|
|
),
|
|
popup,
|
|
);
|
|
}
|
|
if self.thumbnail_open
|
|
&& 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 [image, _, mut details] = Layout::horizontal([
|
|
Constraint::Fill(3),
|
|
Constraint::Length(2),
|
|
Constraint::Fill(2),
|
|
])
|
|
.areas(block.inner(popup));
|
|
details.width = details.width.saturating_sub(2);
|
|
let metadata = Paragraph::new(vec![
|
|
Line::raw(video.title.as_str()).bold(),
|
|
Line::from(vec![
|
|
video.channel.as_deref().unwrap_or("Unknown").into(),
|
|
" · ".into(),
|
|
video
|
|
.publication_date
|
|
.as_deref()
|
|
.unwrap_or("Unknown")
|
|
.into(),
|
|
])
|
|
.dim(),
|
|
])
|
|
.wrap(Wrap { trim: true });
|
|
let height = if details.is_empty() {
|
|
0
|
|
} else {
|
|
metadata
|
|
.line_count(details.width)
|
|
.min(details.height as usize) as u16
|
|
};
|
|
let mut excerpt = details;
|
|
let gap = (excerpt.height - height).min(1);
|
|
excerpt.y += height + gap;
|
|
excerpt.height -= height + gap;
|
|
details.height = height;
|
|
frame.render_widget(metadata, details);
|
|
let description = match video.description.as_deref() {
|
|
Some("") => "This video has no description.",
|
|
Some(description) => description,
|
|
None => self
|
|
.description_error
|
|
.as_deref()
|
|
.unwrap_or("Loading full description..."),
|
|
};
|
|
frame.render_widget(
|
|
Paragraph::new(description).wrap(Wrap { trim: false }).dim(),
|
|
excerpt,
|
|
);
|
|
// Drawing polls completion and starts resize work before we choose the title state.
|
|
self.thumbnail.draw(frame, image);
|
|
frame.render_widget(
|
|
block.title(loading_title(
|
|
" Thumbnail ",
|
|
"· t/Esc close ",
|
|
(self.thumbnail.is_loading() || self.description_job.is_some())
|
|
.then_some(spinner),
|
|
)),
|
|
popup,
|
|
);
|
|
}
|
|
self.draw_category_dialog(frame);
|
|
}
|
|
|
|
fn draw_category_dialog(&mut self, frame: &mut Frame) {
|
|
let Some(dialog) = &mut self.category_dialog else {
|
|
return;
|
|
};
|
|
let area = frame.area();
|
|
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(5).clamp(7, 16) 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 {
|
|
" Tags "
|
|
})
|
|
.title_bottom(dialog.label.as_str())
|
|
.border_style(Style::new().fg(Color::Cyan));
|
|
let [body, error, help] = Layout::vertical([
|
|
Constraint::Min(1),
|
|
Constraint::Length(if dialog.error.is_some() { 2 } else { 0 }),
|
|
Constraint::Length(1),
|
|
])
|
|
.areas(block.inner(popup));
|
|
frame.render_widget(block, popup);
|
|
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("No tags. Press n to create one."), body);
|
|
} else {
|
|
frame.render_stateful_widget(
|
|
List::new(dialog.tags.iter().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() {
|
|
"Enter create · Esc back · Ctrl-U clear"
|
|
} else {
|
|
"j/k move · Space toggle · Enter save · n new · Esc cancel"
|
|
})
|
|
.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: &'static 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 delete_input(text: &mut String, key: KeyEvent) -> bool {
|
|
match key.code {
|
|
KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => text.clear(),
|
|
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => text.clear(),
|
|
KeyCode::Backspace | KeyCode::Char('w')
|
|
if key.modifiers.contains(if key.code == KeyCode::Backspace {
|
|
KeyModifiers::ALT
|
|
} else {
|
|
KeyModifiers::CONTROL
|
|
}) =>
|
|
{
|
|
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);
|
|
}
|
|
KeyCode::Backspace => {
|
|
text.pop();
|
|
}
|
|
_ => return false,
|
|
}
|
|
true
|
|
}
|
|
|
|
fn main() -> io::Result<()> {
|
|
let options = Options::parse();
|
|
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(),
|
|
)
|
|
}
|
|
|
|
#[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.views[0].videos[0].description =
|
|
Some(format!("{}CLIPPED SENTINEL", "Excerpt line\n".repeat(30)));
|
|
app.views[0].selection.select(Some(0));
|
|
app.thumbnail_open = true;
|
|
for (width, height) 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 title = buffer
|
|
.cell((if width == 80 { 46 } else { 69 }, popup.y + 1))
|
|
.unwrap();
|
|
assert_eq!(title.symbol(), "F");
|
|
assert!(title.modifier.contains(Modifier::BOLD));
|
|
assert!(!title.modifier.contains(Modifier::DIM));
|
|
let excerpt_y = *excerpt_rows
|
|
.last()
|
|
.expect("description fills remaining space");
|
|
assert_eq!(excerpt_y, popup.bottom() - 2);
|
|
let excerpt = buffer
|
|
.cell((if width == 80 { 46 } else { 69 }, excerpt_y))
|
|
.unwrap();
|
|
assert_eq!(excerpt.symbol(), "E");
|
|
assert!(excerpt.modifier.contains(Modifier::DIM));
|
|
assert!(!excerpt.modifier.contains(Modifier::BOLD));
|
|
}
|
|
}
|
|
|
|
#[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()
|
|
.map(|name| View::new(name.into()))
|
|
.collect();
|
|
app.view_index = 4;
|
|
app.mode = Mode::Insert;
|
|
for (width, height, language, japanese, chinese, cursor_y) in [
|
|
(30, 20, Some((1, 1)), (1, 2), (14, 2), 4),
|
|
(56, 20, Some((17, 0)), (38, 0), (51, 0), 2),
|
|
(55, 20, Some((17, 0)), (38, 0), (1, 1), 3),
|
|
(9, 20, None, (1, 3), (1, 4), 6),
|
|
(30, 12, Some((1, 0)), (1, 1), (14, 1), 3),
|
|
] {
|
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
|
terminal
|
|
.draw(|frame| app.draw(frame, Duration::ZERO))
|
|
.unwrap();
|
|
let buffer = terminal.backend().buffer();
|
|
if let Some((x, y)) = language {
|
|
for (offset, character) in "Language immersion".chars().enumerate() {
|
|
assert_eq!(
|
|
buffer.cell((x + offset as u16, y)).unwrap().symbol(),
|
|
character.to_string(),
|
|
"whole label at width {width}, height {height}"
|
|
);
|
|
}
|
|
}
|
|
assert_eq!(buffer.cell(japanese).unwrap().symbol(), "日");
|
|
assert_ne!(buffer.cell(japanese).unwrap().fg, Color::Cyan);
|
|
assert_eq!(buffer.cell(chinese).unwrap().symbol(), "中");
|
|
assert_eq!(
|
|
buffer.cell((chinese.0 + 2, chinese.1)).unwrap().symbol(),
|
|
"文"
|
|
);
|
|
assert_eq!(buffer.cell(chinese).unwrap().fg, Color::Cyan);
|
|
assert_eq!(
|
|
terminal.get_cursor_position().unwrap(),
|
|
(1, cursor_y).into()
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn search_pages_preserve_relevance_selection_and_cached_descriptions() {
|
|
let mut view = View::new("Search".into());
|
|
let mut first = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","timestamp":100},{"id":"rQ_J9WH6CGk","title":"Second","timestamp":200}]}"#,
|
|
)
|
|
.unwrap();
|
|
first.has_more = true;
|
|
view.accept_page(first, false).unwrap();
|
|
view.selection.select(Some(1));
|
|
view.videos[1].description = Some("Cached full description.".into());
|
|
let mut next = youtube::search_results(
|
|
r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Duplicate"},{"id":"l-GaaivVJCE","title":"Relevant","timestamp":50},{"id":"l-GaaivVJCE","title":"Repeated"},{"id":"aaaaaaaaaaa","title":"Later","timestamp":300}]}"#,
|
|
)
|
|
.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.videos[1].description.as_deref(),
|
|
Some("Cached full description.")
|
|
);
|
|
assert_eq!(view.selection.selected(), Some(1));
|
|
assert_eq!(view.next_start, 41);
|
|
assert!(view.has_more);
|
|
}
|
|
|
|
#[test]
|
|
fn feed_pages_hold_short_channel_tails_until_newer_long_histories_finish() {
|
|
let mut view = View::new("Feed".into());
|
|
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));
|
|
view.videos[PAGE_SIZE - 1].description = Some("Keep this cache.".into());
|
|
let second = youtube::feed_results(&format!("{}\n{short}", long_page(180))).unwrap();
|
|
view.accept_page(second, true).unwrap();
|
|
assert_eq!(view.videos.len(), PAGE_SIZE * 2);
|
|
assert_eq!(view.videos.last().unwrap().timestamp, Some(180));
|
|
assert_eq!(view.pending.len(), 2);
|
|
assert_eq!(view.selection.selected(), Some(PAGE_SIZE - 1));
|
|
assert_eq!(
|
|
view.videos[PAGE_SIZE - 1].description.as_deref(),
|
|
Some("Keep this cache.")
|
|
);
|
|
view.accept_page(
|
|
youtube::feed_results(
|
|
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Long last row","timestamp":179}]} {"entries":[]}"#,
|
|
).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());
|
|
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.views[0].videos[17].description = Some("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!(
|
|
app.views[0].videos[17].description.as_deref(),
|
|
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.description_open = true;
|
|
assert!(!app.can_prefetch());
|
|
app.description_open = false;
|
|
app.thumbnail_open = true;
|
|
assert!(!app.can_prefetch());
|
|
app.thumbnail_open = false;
|
|
app.category_dialog = Some(CategoryDialog {
|
|
channel: String::new(),
|
|
handle: String::new(),
|
|
label: String::new(),
|
|
tags: BTreeMap::new(),
|
|
selection: ListState::default(),
|
|
name: None,
|
|
error: None,
|
|
});
|
|
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 {
|
|
channel: String::new(),
|
|
handle: String::new(),
|
|
label: String::new(),
|
|
tags: BTreeMap::new(),
|
|
selection: ListState::default(),
|
|
name: Some(String::new()),
|
|
error: None,
|
|
});
|
|
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 {
|
|
channel: "https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".into(),
|
|
handle: "@jawed".into(),
|
|
label: "jawed".into(),
|
|
tags: BTreeMap::new(),
|
|
selection: ListState::default(),
|
|
name: Some(input.into()),
|
|
error: None,
|
|
});
|
|
} 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,
|
|
description: None,
|
|
},
|
|
Video {
|
|
id: "rQ_J9WH6CGk".into(),
|
|
title: "Rust course".into(),
|
|
channel: None,
|
|
channel_id: None,
|
|
uploader_id: None,
|
|
timestamp: None,
|
|
publication_date: None,
|
|
description: 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('c'), 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('c'), KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_none());
|
|
app.views[0].videos[0].uploader_id = handle;
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), 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 {
|
|
categories: [("Rust".into(), rust.clone())].into(),
|
|
channels: BTreeMap::new(),
|
|
});
|
|
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 {
|
|
categories: [
|
|
(
|
|
"Books".into(),
|
|
vec!["https://www.youtube.com/@example".into()],
|
|
),
|
|
("Rust".into(), rust),
|
|
]
|
|
.into(),
|
|
channels: BTreeMap::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 {
|
|
categories: [("Rust".into(), vec![])].into(),
|
|
channels: BTreeMap::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 {
|
|
channels: [("@RustVideos".into(), registered.into())].into(),
|
|
categories: [
|
|
("Direct".into(), vec![direct.into()]),
|
|
("Tagged".into(), vec![registered.into()]),
|
|
]
|
|
.into(),
|
|
},
|
|
);
|
|
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.views[1].videos[1].description = Some("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 {
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Direct".into(), vec![direct.into()]),
|
|
("Renamed".into(), vec![registered.into()]),
|
|
("Tagged".into(), vec![]),
|
|
]
|
|
.into(),
|
|
});
|
|
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!(
|
|
app.views[1].videos[1].description.as_deref(),
|
|
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 {
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Direct".into(), vec![direct.into()]),
|
|
("Renamed".into(), vec![]),
|
|
("Tagged".into(), vec![]),
|
|
]
|
|
.into(),
|
|
});
|
|
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 {
|
|
channels,
|
|
categories: app.config.categories.clone(),
|
|
});
|
|
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 {
|
|
channels,
|
|
categories: app.config.categories.clone(),
|
|
});
|
|
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 {
|
|
channels: [(
|
|
"@RustVideos".into(),
|
|
"https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(),
|
|
)]
|
|
.into(),
|
|
categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(),
|
|
});
|
|
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 {
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
});
|
|
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 {
|
|
channels: app.config.channels.clone(),
|
|
categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(),
|
|
});
|
|
assert_eq!(app.view_index, 0);
|
|
app.switch_view(2);
|
|
app.apply_config(config::Config {
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
});
|
|
assert_eq!(app.view_index, 3);
|
|
assert_eq!(app.views[3].selection.selected(), Some(1));
|
|
|
|
app.switch_view(4);
|
|
app.apply_config(config::Config {
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Beta".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
("Search".into(), vec![]),
|
|
]
|
|
.into(),
|
|
});
|
|
assert_eq!(app.view_index, 5);
|
|
assert_eq!(app.views[5].selection.selected(), Some(0));
|
|
app.apply_config(config::Config {
|
|
channels: app.config.channels.clone(),
|
|
categories: [
|
|
("Alpha".into(), vec![]),
|
|
("Beta".into(), vec![]),
|
|
("Feed".into(), vec![]),
|
|
]
|
|
.into(),
|
|
});
|
|
assert_eq!(app.view_index, 0);
|
|
assert_eq!(app.views[0].selection.selected(), Some(1));
|
|
app.switch_view(4);
|
|
app.apply_config(config::Config {
|
|
channels: app.config.channels.clone(),
|
|
categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(),
|
|
});
|
|
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('c'), 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('c'), 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('c'), 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 thumbnails_are_normal_mode_only_and_other_popups_dismiss_them() {
|
|
let mut app = app(&["tubular"]);
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
|
|
assert_eq!(app.query, "t");
|
|
assert!(!app.thumbnail_open);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
|
|
assert!(!app.thumbnail_open);
|
|
|
|
app.views[0].videos = youtube::search_results(
|
|
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Me at the zoo","channel_id":"UC4QobU6STFB0P71PMvOGN5A","uploader_id":"@jawed"}]}"#,
|
|
).unwrap().videos;
|
|
app.views[0].videos[0].description = Some("A cached full description.".into());
|
|
app.views[0].selection.select(Some(0));
|
|
app.views[0].loaded = true;
|
|
app.thumbnail_open = true;
|
|
app.key(KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE));
|
|
assert!(app.description_open);
|
|
assert!(!app.thumbnail_open);
|
|
assert!(app.description_job.is_none());
|
|
|
|
app.thumbnail_open = true;
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
assert!(!app.description_open);
|
|
assert!(!app.thumbnail_open);
|
|
assert_eq!(app.mode, Mode::Normal);
|
|
app.thumbnail_open = true;
|
|
app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE));
|
|
assert!(app.category_dialog.is_some());
|
|
assert!(!app.thumbnail_open);
|
|
app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));
|
|
app.thumbnail_open = true;
|
|
app.key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
|
|
assert_eq!(app.mode, Mode::Insert);
|
|
assert!(!app.thumbnail_open);
|
|
}
|
|
}
|