feat: add categorized channel subscription feeds
This commit is contained in:
+521
-62
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user