feat: add categorized channel subscription feeds

This commit is contained in:
2026-10-09 21:33:31 +02:00
parent 3954de092e
commit 656474df5b
5 changed files with 1107 additions and 84 deletions
+521 -62
View File
@@ -7,13 +7,19 @@
//! 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.
//! Channel categories live in `$XDG_CONFIG_HOME/tubular/config.toml`, or `~/.config/tubular/config.toml`.
//! Under `[categories]`, write `Rust = ["@RustVideos"]`; channel URLs and UC IDs also work.
//! Normal-mode c opens the category picker; n there prompts for a new name, Enter saves, Esc cancels.
//! Tab/Shift-Tab switch the independent Search/category views; r reloads config and refreshes.
//! Feeds merge up to 20 videos per channel's /videos tab, ordered by approximate upload dates.
//! IINA receives `--stdin`; other players must read standard input when passed `-`.
//! Audio and video are streamed through a pipe, never saved to a video file.
//! See `--help` for controls; replacing playback or quitting cancels its processes.
mod config;
mod youtube;
use std::{ffi::OsString, io, time::Duration};
use std::{ffi::OsString, io, path::PathBuf, time::Duration};
use clap::Parser;
use ratatui::{
@@ -22,7 +28,7 @@ use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize},
text::Line,
widgets::{Block, Clear, Paragraph, Row, Table, TableState, Wrap},
widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Tabs, Wrap},
};
use youtube::{Job, Playback, Video};
@@ -30,7 +36,7 @@ use youtube::{Job, Playback, Video};
#[command(
version,
about = "Search YouTube and stream videos to your chosen player",
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; q quits.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nThe centered description window uses up to 70% width and 60% height, capped at 96 by 20 cells.\nWhile it is open, 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.\nCtrl-U clears in INSERT; Ctrl-C quits in either mode.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
after_help = "Requires yt-dlp, FFmpeg, and a player on PATH.\nDefaults to IINA on macOS and mpv elsewhere; --player overrides the default.\nIINA receives '--stdin'; other players receive '-' to read audio/video from standard input.\nWithout a CLI query, start in INSERT; a CLI query is searched automatically in NORMAL.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nNORMAL: j/k or arrows select; Enter streams; i edits the query; d toggles description; c saves a channel; q quits.\nTab/Shift-Tab switch Search/category views; r reloads config and refreshes the current view.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[categories]\nRust = [\"@RustVideos\"]\nChannel entries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\nFeeds combine up to 20 videos per channel's /videos tab, newest-first using approximate upload dates.\nCATEGORY PICKER: j/k or arrows choose; Enter saves; n prompts for a new category; Esc cancels.\nNEW CATEGORY: type a name; Enter creates it and saves the channel; Esc returns to the picker; Ctrl-U clears.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nThe centered description window uses up to 70% width and 60% height, capped at 96 by 20 cells.\nWhile it is open, 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.\nCtrl-U clears in INSERT; Ctrl-C quits in any mode or popup.\nSelecting another video replaces playback. Quitting stops playback.\nExample: tubular --player mpv --player-arg=--volume=50 \"rust programming\""
)]
struct Options {
/// Player executable (IINA or a player supporting '-' for standard input).
@@ -51,13 +57,44 @@ enum Mode {
Normal,
}
struct View {
name: String,
title: String,
videos: Vec<Video>,
selection: TableState,
loaded: bool,
}
impl View {
fn new(name: String) -> Self {
Self {
title: format!(" {name} "),
name,
videos: Vec::new(),
selection: TableState::default(),
loaded: false,
}
}
}
struct CategoryDialog {
channel: String,
label: String,
selection: ListState,
name: Option<String>,
error: Option<String>,
}
struct App {
options: Options,
query: String,
mode: Mode,
submitted_query: String,
videos: Vec<Video>,
selection: TableState,
config_path: PathBuf,
config: config::Config,
views: Vec<View>,
view_index: usize,
category_dialog: Option<CategoryDialog>,
description_open: bool,
description_job: Option<(usize, Job)>,
description_error: Option<String>,
@@ -70,20 +107,25 @@ struct App {
}
impl App {
fn new(options: Options) -> Self {
fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self {
let query = options.query.clone().unwrap_or_default();
let mode = if query.trim().is_empty() {
Mode::Insert
} else {
Mode::Normal
};
let mut views = vec![View::new("Search".into())];
views.extend(config.categories.keys().cloned().map(View::new));
Self {
options,
query,
mode,
submitted_query: String::new(),
videos: Vec::new(),
selection: TableState::default(),
config_path,
config,
views,
view_index: 0,
category_dialog: None,
description_open: false,
description_job: None,
description_error: None,
@@ -102,36 +144,244 @@ impl App {
self.status = "Enter a search first.".into();
return;
}
if query == self.submitted_query {
if query != self.submitted_query {
self.submitted_query = query.to_owned();
self.views[0].loaded = false;
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.description_open = false;
self.description_job = None;
self.description_viewport = None;
if !self.views[index].loaded {
if self.job.is_none() {
self.load_view();
}
} else {
self.status = format!(
"{} videos in {}. Select a video to stream.",
self.views[index].videos.len(),
self.views[index].name
);
}
}
fn load_view(&mut self) {
self.job = None;
self.description_open = false;
self.description_job = None;
let view = &mut self.views[self.view_index];
view.videos.clear();
view.selection = TableState::default();
view.loaded = false;
let result = if self.view_index == 0 {
if self.submitted_query.is_empty() {
self.status = "Type a query, then press Enter.".into();
return;
}
self.status = format!("Searching for {}...", self.submitted_query);
Job::search(&self.submitted_query)
} else {
let channels = &self.config.categories[&view.name];
if channels.is_empty() {
view.loaded = true;
self.status = format!(
"{} has no channels. Press c on a search result to add one.",
view.name
);
return;
}
self.status = format!("Loading {}...", view.name);
Job::feed(channels)
};
match result {
Ok(job) => self.job = Some(job),
Err(error) => self.status = error.to_string(),
}
}
fn apply_config(&mut self, config: config::Config) {
if config.categories == self.config.categories {
return;
}
match Job::search(query) {
Ok(job) => {
self.job = Some(job);
self.description_open = false;
self.description_job = None;
self.submitted_query = query.to_owned();
self.videos.clear();
self.selection = TableState::default();
self.status = format!("Searching for {query}...");
let active_name = (self.view_index != 0).then(|| self.views[self.view_index].name.clone());
let mut old = std::mem::take(&mut self.views).into_iter();
self.views.push(old.next().expect("search view exists"));
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.loaded = false;
view.videos.clear();
view.selection = TableState::default();
}
self.views.push(view);
}
self.view_index = active_name
.and_then(|name| self.views.iter().skip(1).position(|view| view.name == name))
.map(|index| index + 1)
.unwrap_or(0);
self.config = config;
self.job = None;
self.description_open = false;
self.description_job = None;
}
fn refresh(&mut self) {
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) {
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 channel = format!("https://www.youtube.com/channel/{id}");
let label = format!(" {} ", 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();
selection.select((!self.config.categories.is_empty()).then_some(0));
self.description_open = false;
self.description_job = None;
self.category_dialog = Some(CategoryDialog {
channel,
label,
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::Backspace => {
name.pop();
}
KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => name.clear(),
KeyCode::Char(c)
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
name.push(c);
}
_ => {}
}
} else {
let count = self.config.categories.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::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),
));
}
_ => {}
}
}
if key.code == KeyCode::Enter {
let category = dialog
.name
.as_deref()
.map(str::trim)
.map(str::to_owned)
.or_else(|| {
dialog
.selection
.selected()
.and_then(|index| self.config.categories.keys().nth(index))
.cloned()
});
if let Some(category) = category {
match config::add_channel(&self.config_path, &category, &dialog.channel) {
Ok(config) => {
self.apply_config(config);
self.switch_view(self.view_index);
self.status = format!("Saved channel to {category}.");
return;
}
Err(error) => dialog.error = Some(error.to_string()),
}
} else {
dialog.error = Some("Press n to create a category.".into());
}
}
self.category_dialog = Some(dialog);
}
fn load_description(&mut self) {
self.description_job = None;
self.description_error = None;
self.description_scroll = 0;
self.description_viewport = None;
let Some(index) = self
let view = &self.views[self.view_index];
let Some(index) = view
.selection
.selected()
.filter(|&index| index < self.videos.len())
.filter(|&index| index < view.videos.len())
else {
return;
};
let video = &self.videos[index];
let video = &view.videos[index];
if video.description.is_some() {
return;
}
@@ -145,10 +395,11 @@ impl App {
if self.job.is_some() {
return;
}
let Some(video) = self
let view = &self.views[self.view_index];
let Some(video) = view
.selection
.selected()
.and_then(|index| self.videos.get(index))
.and_then(|index| view.videos.get(index))
else {
return;
};
@@ -168,13 +419,30 @@ impl App {
fn update(&mut self) {
if let Some(result) = self.job.as_mut().and_then(Job::poll) {
self.job = None;
self.status = match result.and_then(|output| youtube::search_results(&output)) {
let parsed = result.and_then(|output| {
if self.view_index == 0 {
youtube::search_results(&output)
} else {
youtube::feed_results(&output)
}
});
self.status = match parsed {
Ok(videos) => {
self.videos = videos;
self.selection = TableState::default();
self.selection
.select((!self.videos.is_empty()).then_some(0));
format!("{} results. Select a video to stream.", self.videos.len())
let view = &mut self.views[self.view_index];
view.videos = videos;
view.loaded = true;
view.selection = TableState::default();
view.selection
.select((!view.videos.is_empty()).then_some(0));
if self.view_index == 0 {
format!("{} results. Select a video to stream.", view.videos.len())
} else {
format!(
"{} videos in {}. Select a video to stream.",
view.videos.len(),
view.name
)
}
}
Err(error) => error,
};
@@ -188,7 +456,7 @@ impl App {
.description_job
.take()
.expect("completed description job exists");
if let Some(video) = self.videos.get_mut(index) {
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),
@@ -209,7 +477,11 @@ impl App {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return true;
}
let selected_before = self.selection.selected();
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;
if self.mode == Mode::Insert {
match key.code {
@@ -242,10 +514,10 @@ impl App {
}
KeyCode::Char('d') => {
self.description_open = !self.description_open
&& self
&& self.views[self.view_index]
.selection
.selected()
.is_some_and(|index| index < self.videos.len());
.is_some_and(|index| index < self.views[self.view_index].videos.len());
}
KeyCode::Esc => self.description_open = false,
KeyCode::Down if self.description_open => {
@@ -276,38 +548,62 @@ impl App {
.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.selection.select_next(),
KeyCode::Up | KeyCode::Char('k') => self.selection.select_previous(),
KeyCode::Home | KeyCode::Char('g') => self.selection.select_first(),
KeyCode::End | KeyCode::Char('G') => self.selection.select_last(),
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()
}
_ => {}
}
}
if let Some(index) = self
let view = &mut self.views[self.view_index];
if let Some(index) = view
.selection
.selected()
.filter(|_| !self.videos.is_empty())
.filter(|_| !view.videos.is_empty())
{
self.selection
.select(Some(index.min(self.videos.len() - 1)));
view.selection
.select(Some(index.min(view.videos.len() - 1)));
}
if !self.description_open {
self.description_job = None;
} else if !description_was_open || self.selection.selected() != selected_before {
} else if !description_was_open
|| self.views[self.view_index].selection.selected() != selected_before
{
self.load_description();
}
false
}
fn draw(&mut self, frame: &mut Frame) {
let [search, results, status, help] = Layout::vertical([
let [tabs, search, results, status, help] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(3),
Constraint::Min(3),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(frame.area());
frame.render_widget(
Tabs::new(self.views.iter().map(|view| view.name.as_str()))
.select(self.view_index)
.highlight_style(Style::new().fg(Color::Cyan).bold()),
tabs,
);
let search_style = if self.mode == Mode::Insert {
Style::new().fg(Color::Cyan)
} else {
@@ -328,8 +624,9 @@ impl App {
),
search,
);
let view = &mut self.views[self.view_index];
let table = Table::new(
self.videos.iter().map(|video| {
view.videos.iter().map(|video| {
Row::new([
video.title.as_str(),
video.channel.as_deref().unwrap_or("Unknown"),
@@ -338,10 +635,14 @@ impl App {
[Constraint::Percentage(75), Constraint::Percentage(25)],
)
.header(Row::new(["Video", "Channel"]).bold())
.block(Block::bordered().title(" Results "))
.block(Block::bordered().title(if self.view_index == 0 {
" Results "
} else {
view.title.as_str()
}))
.row_highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan))
.highlight_symbol("> ");
frame.render_stateful_widget(table, results, &mut self.selection);
frame.render_stateful_widget(table, results, &mut view.selection);
frame.render_widget(
Paragraph::new(self.status.as_str())
.wrap(Wrap { trim: true })
@@ -356,15 +657,15 @@ impl App {
"NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit"
}
Mode::Normal => {
"NORMAL · j/k select · Enter stream · i insert · d description · q quit"
"NORMAL · j/k select · Enter play · i search · d info · c save · Tab views · r refresh · q quit"
}
};
frame.render_widget(Paragraph::new(help_text).dim(), help);
if self.description_open
&& let Some(video) = self
&& let Some(video) = self.views[self.view_index]
.selection
.selected()
.and_then(|index| self.videos.get(index))
.and_then(|index| self.views[self.view_index].videos.get(index))
{
let area = frame.area();
let width = area
@@ -420,6 +721,86 @@ impl App {
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 {
self.config.categories.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 category "
} else {
" Save channel "
})
.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 self.config.categories.is_empty() {
frame.render_widget(
Paragraph::new("No categories. Press n to create one."),
body,
);
} else {
frame.render_stateful_widget(
List::new(self.config.categories.keys().map(String::as_str))
.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 & save · Esc back · Ctrl-U clear"
} else {
"j/k select · Enter save · n new · Esc cancel"
})
.dim(),
help,
);
}
fn run(mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
@@ -441,7 +822,10 @@ impl App {
}
fn main() -> io::Result<()> {
let app = App::new(Options::parse());
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))
}
@@ -449,41 +833,53 @@ fn main() -> io::Result<()> {
mod tests {
use super::*;
fn app(args: &[&str]) -> App {
App::new(
Options::parse_from(args),
PathBuf::new(),
config::Config::default(),
)
}
#[test]
fn modal_keys_edit_or_navigate_without_submitting_on_escape() {
let mut app = App::new(Options::parse_from(["tubular"]));
let mut app = app(&["tubular"]);
assert_eq!(app.mode, Mode::Insert);
app.videos = vec![
app.views[0].videos = vec![
Video {
id: "jNQXAC9IVRw".into(),
title: "Me at the zoo".into(),
channel: None,
channel_id: None,
timestamp: None,
description: None,
},
Video {
id: "rQ_J9WH6CGk".into(),
title: "Rust course".into(),
channel: None,
channel_id: None,
timestamp: None,
description: None,
},
];
app.selection.select(Some(0));
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.selection.selected(), Some(0));
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.selection.selected(), Some(1));
assert_eq!(app.views[0].selection.selected(), Some(1));
app.key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE));
assert_eq!(app.selection.selected(), Some(0));
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));
@@ -492,13 +888,76 @@ mod tests {
assert!(app.query.is_empty());
assert!(app.job.is_none());
assert_eq!(self::app(&["tubular", "rust"]).mode, Mode::Normal);
assert_eq!(self::app(&["tubular", " "]).mode, Mode::Insert);
}
#[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"},{"id":"rQ_J9WH6CGk","title":"Another video"}]}"#;
app.views[0].videos = youtube::search_results(videos).unwrap();
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));
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'] {
assert!(!app.key(KeyEvent::new(KeyCode::Char(c), KeyModifiers::NONE)));
}
assert_eq!(
App::new(Options::parse_from(["tubular", "rust"])).mode,
Mode::Normal
);
assert_eq!(
App::new(Options::parse_from(["tubular", " "])).mode,
Mode::Insert
app.category_dialog.as_ref().unwrap().name.as_deref(),
Some("jkqi")
);
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::Esc, KeyModifiers::NONE));
assert!(app.category_dialog.is_none());
assert_eq!(app.mode, Mode::Normal);
let rust = vec!["https://www.youtube.com/@RustVideos".into()];
app.apply_config(config::Config {
categories: [("Rust".into(), rust.clone())].into(),
});
app.views[0].selection.select(Some(1));
app.views[1].videos = youtube::search_results(videos).unwrap();
app.views[1].loaded = true;
app.views[1].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE));
assert_eq!(app.views[1].selection.selected(), Some(1));
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.apply_config(config::Config {
categories: [
(
"Books".into(),
vec!["https://www.youtube.com/@example".into()],
),
("Rust".into(), rust),
]
.into(),
});
assert_eq!(app.view_index, 2);
assert_eq!(app.views[2].name, "Rust");
assert_eq!(app.views[2].selection.selected(), Some(1));
assert!(app.views[2].loaded);
app.apply_config(config::Config {
categories: [("Rust".into(), vec![])].into(),
});
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());
}
}