From 766fef625ccce14afc953735b88705e974784d85 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 19:33:08 +0200 Subject: [PATCH] feat: color category-saved channel names --- README.md | 2 + src/main.rs | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 01df025..0a37767 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,8 @@ Favorites = ["@jawed"] Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly. +Video tables color channel names **cyan** when the creator belongs to at least one category. Untagged registrations keep the default text color; selected rows retain their black-on-cyan highlight. Membership is matched by channel ID or handle, not the displayed name, and updates after saving tags or reloading config. + - **Search** and **Feed** are permanent tabs. Feed combines all `[channels]` registrations, including untagged channels, plus channels entered directly in categories, without duplicates. - Browsing an unpinned category opens one temporary tab; choosing another replaces it. Pinned categories keep their own tabs, marked with a dimmed `p`. - Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`. diff --git a/src/main.rs b/src/main.rs index 14a56e7..5788395 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,7 +111,7 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Style, Stylize}, text::{Line, Span, Text}, - widgets::{Block, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap}, + widgets::{Block, Cell, Clear, List, ListState, Paragraph, Row, Table, TableState, Wrap}, }; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; @@ -1677,10 +1677,24 @@ impl App { let view = &mut self.views[self.view_index]; let table = Table::new( view.videos.iter().map(|video| { + let tagged = self.config.categories.values().flatten().any(|identity| { + video.channel_id.as_deref().is_some_and(|id| { + identity.strip_prefix("https://www.youtube.com/channel/") == Some(id) + }) || video.uploader_id.as_deref().is_some_and(|handle| { + identity.strip_prefix("https://www.youtube.com/") == Some(handle) + || (video.channel_id.is_none() + && self.config.channels.get(handle) == Some(identity)) + }) + }); + let channel = Cell::from(video.channel.as_deref().unwrap_or("Unknown")); Row::new([ - video.title.as_str(), - video.channel.as_deref().unwrap_or("Unknown"), - video.publication_date.as_deref().unwrap_or("Unknown"), + Cell::from(video.title.as_str()), + if tagged { + channel.fg(Color::Cyan) + } else { + channel + }, + Cell::from(video.publication_date.as_deref().unwrap_or("Unknown")), ]) }), [ @@ -2210,6 +2224,94 @@ mod tests { app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); } + #[test] + fn video_channel_colors_follow_category_membership_and_preserve_selection_contrast() { + use ratatui::{Terminal, backend::TestBackend}; + + let mut app = app(&["tubular"]); + let tagged_id = "UC4QobU6STFB0P71PMvOGN5A"; + let registered_id = "UCaYhcUwRBNscFNUKTjgPFiA"; + app.config.channels.insert( + "@mapped".into(), + config::normalize_channel(tagged_id).unwrap(), + ); + app.config.channels.insert( + "@registered".into(), + config::normalize_channel(registered_id).unwrap(), + ); + app.config.categories.insert( + "Favorites".into(), + vec![ + config::normalize_channel(tagged_id).unwrap(), + config::normalize_channel("@direct").unwrap(), + ], + ); + let cases = [ + ("TaggedID", Some(tagged_id), None, true), + ("TaggedHandle", None, Some("@direct"), true), + ("MappedHandle", None, Some("@mapped"), true), + ( + "Registered", + Some(registered_id), + Some("@registered"), + false, + ), + ("TaggedID", Some(registered_id), None, false), + ("Unknown", None, None, false), + ]; + app.views[0].videos = youtube::search_results( + &serde_json::json!({ + "entries": cases.iter().map(|(channel, id, handle, _)| serde_json::json!({ + "id": "jNQXAC9IVRw", "title": "Video title", "channel": channel, + "channel_id": id, "uploader_id": handle, "upload_date": "20261011" + })).collect::>() + }) + .to_string(), + ) + .unwrap() + .videos; + app.views[0].selection.select(None); + let mut terminal = Terminal::new(TestBackend::new(120, 24)).unwrap(); + for cleared in [false, true] { + if cleared { + app.config.categories.clear(); + } + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + // One tab row, three search rows, then the table border and header. + for (index, (label, _, _, tagged)) in cases.iter().enumerate() { + let y = 6 + index as u16; + let row: String = (0..120).map(|x| buffer[(x, y)].symbol()).collect(); + let x = row.find(label).expect("channel label is visible") as u16; + assert_eq!( + buffer[(x, y)].fg, + if *tagged && !cleared { + Color::Cyan + } else { + Color::Reset + }, + "{label} in row {index}, cleared={cleared}" + ); + assert_eq!(buffer[(3, y)].fg, Color::Reset, "title stays uncolored"); + assert_eq!(buffer[(108, y)].fg, Color::Reset, "date stays uncolored"); + } + if !cleared { + app.views[0].selection.select(Some(0)); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + let buffer = terminal.backend().buffer(); + let row: String = (0..120).map(|x| buffer[(x, 6)].symbol()).collect(); + let x = row.find("TaggedID").unwrap() as u16; + assert_eq!(buffer[(x, 6)].fg, Color::Black); + assert_eq!(buffer[(x, 6)].bg, Color::Cyan); + app.views[0].selection.select(None); + } + } + } + #[test] fn remapped_preview_and_input_actions_survive_key_only_config_reload() { use ratatui::{Terminal, backend::TestBackend};