From 4be14583b452040dd0c111c5d0676b395efeba80 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 12:23:17 +0200 Subject: [PATCH] feat: browse categories on demand and pin favorite tabs --- src/config.rs | 97 ++++++++++++++ src/main.rs | 349 +++++++++++++++++++++++++++++++++++++++++++++++--- 2 files changed, 428 insertions(+), 18 deletions(-) diff --git a/src/config.rs b/src/config.rs index 7c8c76b..6042679 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ use toml_edit::{Array, DocumentMut, Item, Table, value}; pub struct Config { pub channels: BTreeMap, pub categories: BTreeMap>, + pub pinned_categories: Vec, } impl Config { @@ -51,9 +52,11 @@ pub fn load(path: &Path) -> io::Result { let (_, document) = read_document(path)?; let channels = parse_channels(&document)?; let categories = parse_categories(&document, &channels)?; + let pinned_categories = parse_pins(&document)?; Ok(Config { channels, categories, + pinned_categories, }) } @@ -83,6 +86,7 @@ pub fn set_channel_tags( let mut mappings = parse_channels(&document)?; // Validate the whole file before changing it or creating directories. parse_categories(&document, &mappings)?; + let pinned_categories = parse_pins(&document)?; match mappings.get(&handle) { Some(existing) if existing != &channel => { return Err(invalid(format!( @@ -187,9 +191,62 @@ pub fn set_channel_tags( Ok(Config { channels: mappings, categories, + pinned_categories, }) } +pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result { + let name = category_name(name)?; + let (original, mut document) = read_document(path)?; + let channels = parse_channels(&document)?; + let categories = parse_categories(&document, &channels)?; + let pins = parse_pins(&document)?; + if !categories.contains_key(name) { + return Err(invalid(format!("Unknown category: {name:?}"))); + } + if !document.contains_key("pinned_categories") { + document.insert("pinned_categories", value(Array::new())); + } + let array = document["pinned_categories"] + .as_array_mut() + .expect("pins were validated"); + if let Some(index) = pins.iter().position(|pin| pin == name) { + if !pinned { + array.remove(index); + } + } else if pinned { + array.push(name); + } + let pinned_categories = parse_pins(&document)?; + save(path, original.as_deref(), document.to_string().as_bytes())?; + Ok(Config { + channels, + categories, + pinned_categories, + }) +} + +fn parse_pins(document: &DocumentMut) -> io::Result> { + let Some(item) = document.get("pinned_categories") else { + return Ok(Vec::new()); + }; + let array = item + .as_array() + .ok_or_else(|| invalid("pinned_categories must be an array"))?; + let mut pins = Vec::with_capacity(array.len()); + for item in array.iter() { + let name = category_name( + item.as_str() + .ok_or_else(|| invalid("Pinned categories must be strings"))?, + )?; + if pins.iter().any(|pin| pin == name) { + return Err(invalid(format!("Duplicate pinned category: {name:?}"))); + } + pins.push(name.to_owned()); + } + Ok(pins) +} + fn invalid(message: impl Into) -> io::Error { io::Error::new(io::ErrorKind::InvalidData, message.into()) } @@ -439,6 +496,46 @@ mod tests { } } + #[test] + fn category_pins_persist_without_changing_memberships_or_manual_preferences() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + fs::write(&path, "# favorites\npinned_categories = ['Japanese'] # pin note\n[other]\nvalue = 42\n[categories]\nJapanese = []\nChinese = []\n").unwrap(); + let pinned = set_category_pinned(&path, "Chinese", true).unwrap(); + assert_eq!(pinned.pinned_categories, ["Japanese", "Chinese"]); + let tags = BTreeMap::from([("Japanese".into(), true)]); + let saved = set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap(); + assert_eq!(saved.pinned_categories, ["Japanese", "Chinese"]); + assert_eq!( + saved.categories["Japanese"], + ["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A"] + ); + let unpinned = set_category_pinned(&path, "Japanese", false).unwrap(); + assert_eq!(unpinned.pinned_categories, ["Chinese"]); + assert_eq!(load(&path).unwrap().pinned_categories, ["Chinese"]); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("# favorites") && content.contains("# pin note")); + assert_eq!( + content.parse::().unwrap()["other"]["value"].as_integer(), + Some(42) + ); + assert!(set_category_pinned(&path, "Missing", true).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + for invalid in [ + "pinned_categories = true", + "pinned_categories = [4]", + "pinned_categories = ['A', ' A ']", + ] { + fs::write(&path, format!("{invalid}\n[categories]\nA = []\n")).unwrap(); + let before = fs::read(&path).unwrap(); + assert!(set_category_pinned(&path, "A", true).is_err()); + assert!( + set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").is_err() + ); + assert_eq!(fs::read(&path).unwrap(), before); + } + } + #[test] fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() { let directory = TestDirectory::new(); diff --git a/src/main.rs b/src/main.rs index c4808f1..b2cf2ff 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,9 +33,15 @@ //! 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. +//! Normal-mode s 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. +//! Search and Feed are permanent tabs. Normal-mode c opens the saved-category browser. +//! j/k moves, Enter browses, p immediately persists pin/unpin, and Esc closes the browser. +//! Pinned categories stay visible; browsing another category replaces the single temporary tab. +//! Browsing a pinned category focuses its existing tab and removes the temporary tab. +//! Top-level `pinned_categories = ["Favorites"]` persists pins; missing category names are ignored. +//! Tab/Shift-Tab cycle only visible tabs; hidden views retain cached rows and selection. +//! r reloads config and refreshes the current view. //! Feed includes saved `[channels]` entries, even untagged, plus direct tag entries, without duplicates. //! Untagging is not unsubscribing: remove mappings and tag references from the file to unsubscribe. //! Search pages contain 20 results; Feed/tag pages request 20 uploads per channel's /videos tab. @@ -85,7 +91,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube and open selected videos in Terebi", - after_help = "Requires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --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\"" + 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 browses saved categories; s edits channel tags; q quits.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): j/k or arrows move; Enter browses; p immediately saves pin/unpin; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nHidden category views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\npinned_categories is a top-level array of category names; pins referencing removed categories are ignored.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nNEW TAG: type a name; Enter stages it checked and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nOpening the description fetches full video metadata, preserving paragraphs and caching it for these results.\nPreview windows use up to 70% width and 60% height, capped at 96 by 20 cells.\nIn descriptions, Up/Down scroll lines and PgUp/PgDn scroll pages; j/k still selects videos.\nEsc closes the description; i closes it to edit the query. Scrolling stays within the text.\nTHUMBNAIL: t or Esc closes; j/k or arrows load the selected video's image; d switches to description.\nThumbnails are fetched lazily into memory and fit proportionally inside the popup on resize.\nTerminal graphics are detected at startup, with colored half-block rendering when image protocols are unavailable.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\ni, c, s, 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. @@ -221,6 +227,11 @@ struct CategoryDialog { error: Option, } +struct CategoryBrowser { + selection: ListState, + error: Option, +} + struct App { options: Options, query: String, @@ -232,6 +243,8 @@ struct App { views: Vec, view_index: usize, category_dialog: Option, + category_browser: Option, + temporary_category: Option, thumbnail_open: bool, thumbnail: thumbnail::Thumbnail, description_open: bool, @@ -264,6 +277,8 @@ impl App { views, view_index: 0, category_dialog: None, + category_browser: None, + temporary_category: None, thumbnail_open: false, thumbnail: thumbnail::Thumbnail::new(), description_open: false, @@ -279,6 +294,117 @@ impl App { } } + fn visible_views(&self) -> impl Iterator { + self.views + .iter() + .enumerate() + .filter(|(index, view)| { + *index < 2 || self.config.pinned_categories.contains(&view.name) + }) + .chain(self.temporary_category.as_ref().and_then(|name| { + self.views.iter().enumerate().skip(2).find(|(_, view)| { + &view.name == name && !self.config.pinned_categories.contains(name) + }) + })) + } + + fn cycle_view(&mut self, backwards: bool) { + let next = if backwards { + self.visible_views() + .map(|(index, _)| index) + .take_while(|index| *index != self.view_index) + .last() + .or_else(|| self.visible_views().last().map(|(index, _)| index)) + .unwrap_or(0) + } else { + self.visible_views() + .map(|(index, _)| index) + .skip_while(|index| *index != self.view_index) + .nth(1) + .unwrap_or(0) + }; + self.switch_view(next); + } + + fn open_category_browser(&mut self) { + self.close_thumbnail(); + self.description_open = false; + self.description_job = None; + let mut selection = ListState::default(); + selection.select( + self.config + .categories + .keys() + .position(|name| self.view_index >= 2 && name == &self.views[self.view_index].name) + .or_else(|| (!self.config.categories.is_empty()).then_some(0)), + ); + self.category_browser = Some(CategoryBrowser { + selection, + error: None, + }); + } + + fn browse_category_key(&mut self, key: KeyEvent) { + let mut browser = self + .category_browser + .take() + .expect("category browser is open"); + let count = self.config.categories.len(); + match key.code { + KeyCode::Esc => return, + KeyCode::Down | KeyCode::Char('j') if count != 0 => { + browser.selection.select(Some( + browser + .selection + .selected() + .unwrap_or(0) + .saturating_add(1) + .min(count - 1), + )); + } + KeyCode::Up | KeyCode::Char('k') if count != 0 => { + browser.selection.select(Some( + browser.selection.selected().unwrap_or(0).saturating_sub(1), + )); + } + KeyCode::Enter | KeyCode::Char('p') => { + if let Some(name) = browser + .selection + .selected() + .and_then(|index| self.config.categories.keys().nth(index)) + .cloned() + { + if key.code == KeyCode::Enter { + self.temporary_category = None; + let index = self + .views + .iter() + .skip(2) + .position(|view| view.name == name) + .expect("configured category view exists") + + 2; + self.switch_view(index); + return; + } + let pinned = !self.config.pinned_categories.contains(&name); + match config::set_category_pinned(&self.config_path, &name, pinned) { + Ok(config) => { + self.apply_config(config); + self.switch_view(self.view_index); + browser + .selection + .select(self.config.categories.keys().position(|key| key == &name)); + browser.error = None; + } + Err(error) => browser.error = Some(error.to_string()), + } + } + } + _ => {} + } + self.category_browser = Some(browser); + } + fn search(&mut self) { let query = self.query.trim(); if query.is_empty() { @@ -294,6 +420,14 @@ impl App { } fn switch_view(&mut self, index: usize) { + if index >= 2 + && !self + .config + .pinned_categories + .contains(&self.views[index].name) + { + self.temporary_category = Some(self.views[index].name.clone()); + } if index != self.view_index { self.job = None; self.view_index = index; @@ -425,13 +559,17 @@ impl App { self.job.is_none() && self.mode == Mode::Normal && self.category_dialog.is_none() + && self.category_browser.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 { + if config.categories == self.config.categories + && config.channels == self.config.channels + && config.pinned_categories == self.config.pinned_categories + { return; } let active_index = self.view_index; @@ -467,6 +605,20 @@ impl App { }; self.feed_channels = feed_channels; self.config = config; + if self.temporary_category.as_ref().is_some_and(|name| { + !self.config.categories.contains_key(name) + || self.config.pinned_categories.contains(name) + }) { + self.temporary_category = None; + } + if self.view_index >= 2 + && !self + .config + .pinned_categories + .contains(&self.views[self.view_index].name) + { + self.temporary_category = Some(self.views[self.view_index].name.clone()); + } self.job = None; self.description_open = false; self.description_job = None; @@ -757,6 +909,10 @@ impl App { self.category_key(key); return false; } + if self.category_browser.is_some() { + self.browse_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; @@ -836,12 +992,11 @@ impl App { .max(1); self.description_scroll = self.description_scroll.saturating_sub(page); } - KeyCode::Char('c') => self.open_category_picker(), + KeyCode::Char('c') => self.open_category_browser(), + KeyCode::Char('s') => 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::Tab => self.cycle_view(false), + KeyCode::BackTab => self.cycle_view(true), KeyCode::Enter => self.play(), KeyCode::Down | KeyCode::Char('j') => { self.views[self.view_index].selection.select_next() @@ -892,7 +1047,7 @@ impl App { 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() { + for (index, view) in self.visible_views() { 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) { @@ -995,6 +1150,9 @@ impl App { Mode::Insert => { "INSERT · Enter search → normal · Esc normal · Ctrl-U clear · Ctrl-C quit" } + Mode::Normal if self.category_browser.is_some() => { + "CATEGORIES · j/k · Enter browse · p pin/unpin · Esc close · Ctrl-C quit" + } Mode::Normal if self.description_open => { "NORMAL · ↑/↓ PgUp/PgDn scroll · j/k video · d/Esc close · i insert · q quit" } @@ -1002,7 +1160,7 @@ impl App { "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" + "NORMAL · j/k · Enter play · i search · d info · t image · c browse · s tags · Tab views · r refresh · q quit" } }; frame.render_widget(Paragraph::new(help_text).dim(), help); @@ -1156,6 +1314,59 @@ impl App { ); } self.draw_category_dialog(frame); + self.draw_category_browser(frame); + } + + fn draw_category_browser(&mut self, frame: &mut Frame) { + let Some(browser) = &mut self.category_browser else { + return; + }; + let popup = preview_area(frame.area()); + frame.render_widget(Clear, popup); + let block = Block::bordered() + .title(" Categories · Enter browse · p pin · Esc close ") + .border_style(Style::new().fg(Color::Cyan)); + let [list, error] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(if browser.error.is_some() { 2 } else { 0 }), + ]) + .areas(block.inner(popup)); + frame.render_widget(block, popup); + if self.config.categories.is_empty() { + frame.render_widget( + Paragraph::new("No categories yet. Use s on a video to save its channel.") + .wrap(Wrap { trim: true }) + .dim(), + list, + ); + } else { + let items = self.config.categories.keys().map(|name| { + Line::from(vec![ + if self.config.pinned_categories.contains(name) { + "[p] " + } else { + "[ ] " + } + .into(), + name.as_str().into(), + ]) + }); + frame.render_stateful_widget( + List::new(items) + .highlight_symbol("> ") + .highlight_style(Style::new().fg(Color::Black).bg(Color::Cyan)), + list, + &mut browser.selection, + ); + } + if let Some(message) = browser.error.as_deref() { + frame.render_widget( + Paragraph::new(message) + .wrap(Wrap { trim: true }) + .style(Style::new().fg(Color::Red)), + error, + ); + } } fn draw_category_dialog(&mut self, frame: &mut Frame) { @@ -1350,6 +1561,87 @@ mod tests { ) } + #[test] + fn category_browser_replaces_the_temporary_tab_and_cycles_only_visible_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Beta".into(), vec![]), + ("Gamma".into(), vec![]), + ] + .into(), + ..config::Config::default() + }); + let visible = |app: &App| { + app.visible_views() + .map(|(_, view)| view.name.clone()) + .collect::>() + }; + assert_eq!(visible(&app), ["Search", "Feed"]); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.views[app.view_index].name, "Beta"); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed", "Gamma"]); + app.apply_config(config::Config { + categories: app.config.categories.clone(), + pinned_categories: vec!["Beta".into()], + ..config::Config::default() + }); + assert_eq!(visible(&app), ["Search", "Feed", "Beta", "Gamma"]); + app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(app.view_index, 0); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].name, "Gamma"); + app.key(KeyEvent::new(KeyCode::BackTab, KeyModifiers::SHIFT)); + assert_eq!(app.views[app.view_index].name, "Beta"); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert_eq!(visible(&app), ["Search", "Feed", "Beta"]); + assert!(app.category_browser.is_none()); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert!(app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL))); + } + + #[test] + fn categories_named_search_or_feed_are_distinct_from_permanent_views() { + let mut app = app(&["tubular"]); + app.apply_config(config::Config { + categories: [ + ("Alpha".into(), vec![]), + ("Feed".into(), vec![]), + ("Search".into(), vec![]), + ] + .into(), + ..config::Config::default() + }); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert_eq!( + app.category_browser.as_ref().unwrap().selection.selected(), + Some(0) + ); + app.key(KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + assert_eq!(app.view_index, 3); + assert_eq!(app.views[app.view_index].name, "Feed"); + app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + assert_eq!( + app.category_browser.as_ref().unwrap().selection.selected(), + Some(1) + ); + } + #[test] fn descriptions_are_shared_across_views_and_evict_least_recently_used_entries() { let mut app = app(&["tubular"]); @@ -1508,6 +1800,12 @@ mod tests { .into_iter() .map(|name| View::new(name.into())) .collect(); + app.config.pinned_categories = app + .views + .iter() + .skip(2) + .map(|view| view.name.clone()) + .collect(); app.view_index = 4; app.mode = Mode::Insert; for (width, height, language, japanese, chinese, cursor_y) in [ @@ -1994,14 +2292,14 @@ mod tests { 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)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); assert!(app.category_dialog.is_none()); app.views[0].selection.select(Some(0)); let handle = app.views[0].videos[0].uploader_id.take(); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); assert!(app.category_dialog.is_none()); app.views[0].videos[0].uploader_id = handle; - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!(app.category_dialog.as_ref().unwrap().error.is_some()); @@ -2036,6 +2334,7 @@ mod tests { app.apply_config(config::Config { categories: [("Rust".into(), rust.clone())].into(), channels: BTreeMap::new(), + pinned_categories: vec!["Rust".into()], }); app.views[0].selection.select(Some(1)); app.views[1].videos = youtube::feed_results(videos).unwrap().videos; @@ -2070,6 +2369,7 @@ mod tests { ] .into(), channels: BTreeMap::new(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 3); assert_eq!(app.views[3].name, "Rust"); @@ -2078,6 +2378,7 @@ mod tests { app.apply_config(config::Config { categories: [("Rust".into(), vec![])].into(), channels: BTreeMap::new(), + pinned_categories: Vec::new(), }); assert_eq!(app.views[app.view_index].name, "Rust"); assert!(app.views[app.view_index].videos.is_empty()); @@ -2101,6 +2402,7 @@ mod tests { ("Tagged".into(), vec![registered.into()]), ] .into(), + pinned_categories: Vec::new(), }, ); let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"New","timestamp":200},{"id":"rQ_J9WH6CGk","title":"Old","timestamp":100}]}"#; @@ -2141,6 +2443,7 @@ mod tests { ("Tagged".into(), vec![]), ] .into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 1); assert!(app.views[1].loaded); @@ -2173,6 +2476,7 @@ mod tests { ("Tagged".into(), vec![]), ] .into(), + pinned_categories: Vec::new(), }); assert_eq!(app.feed_channels, vec![direct, registered]); assert!(app.views[1].loaded); @@ -2188,6 +2492,7 @@ mod tests { app.apply_config(config::Config { channels, categories: app.config.categories.clone(), + pinned_categories: Vec::new(), }); assert_eq!(app.feed_channels, vec![direct, added, registered]); assert!(!app.views[1].loaded); @@ -2208,6 +2513,7 @@ mod tests { app.apply_config(config::Config { channels, categories: app.config.categories.clone(), + pinned_categories: Vec::new(), }); assert_eq!(app.feed_channels, vec![direct, registered]); assert!(!app.views[1].loaded); @@ -2227,6 +2533,7 @@ mod tests { )] .into(), categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), + pinned_categories: Vec::new(), }); let videos = r#"{"entries":[{"id":"l-GaaivVJCE","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#; for (index, view) in app.views.iter_mut().enumerate() { @@ -2243,6 +2550,7 @@ mod tests { ("Search".into(), vec![]), ] .into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 1); assert_eq!(app.views[1].selection.selected(), Some(0)); @@ -2255,6 +2563,7 @@ mod tests { app.apply_config(config::Config { channels: app.config.channels.clone(), categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 0); app.switch_view(2); @@ -2266,6 +2575,7 @@ mod tests { ("Search".into(), vec![]), ] .into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 3); assert_eq!(app.views[3].selection.selected(), Some(1)); @@ -2280,6 +2590,7 @@ mod tests { ("Search".into(), vec![]), ] .into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 5); assert_eq!(app.views[5].selection.selected(), Some(0)); @@ -2291,6 +2602,7 @@ mod tests { ("Feed".into(), vec![]), ] .into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 0); assert_eq!(app.views[0].selection.selected(), Some(1)); @@ -2298,6 +2610,7 @@ mod tests { app.apply_config(config::Config { channels: app.config.channels.clone(), categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), + pinned_categories: Vec::new(), }); assert_eq!(app.view_index, 0); assert_eq!(app.views[1].name, "Feed"); @@ -2350,7 +2663,7 @@ mod tests { .unwrap().videos; app.views[0].loaded = true; app.views[0].selection.select(Some(0)); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); assert_eq!( app.category_dialog.as_ref().unwrap().tags, [ @@ -2397,12 +2710,12 @@ mod tests { 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('s'), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(std::fs::read_to_string(&path).unwrap(), committed); std::fs::write(&path, "[categories]\n").unwrap(); - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); assert!( app.category_dialog .as_ref() @@ -2447,7 +2760,7 @@ mod tests { assert!(!app.thumbnail_open); assert_eq!(app.mode, Mode::Normal); app.thumbnail_open = true; - app.key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)); + app.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::NONE)); assert!(app.category_dialog.is_some()); assert!(!app.thumbnail_open); app.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE));