feat: color category-saved channel names

This commit is contained in:
2026-10-11 19:33:08 +02:00
parent 8dee7eb382
commit 766fef625c
2 changed files with 108 additions and 4 deletions
+2
View File
@@ -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. 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. - **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`. - 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`. - Removing a channel from a category does **not** unsubscribe it from Feed. To unsubscribe, remove its `[channels]` mappings and category references, then press `r`.
+106 -4
View File
@@ -111,7 +111,7 @@ use ratatui::{
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
style::{Color, Style, Stylize}, style::{Color, Style, Stylize},
text::{Line, Span, Text}, 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}; use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video};
@@ -1677,10 +1677,24 @@ impl App {
let view = &mut self.views[self.view_index]; let view = &mut self.views[self.view_index];
let table = Table::new( let table = Table::new(
view.videos.iter().map(|video| { 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([ Row::new([
video.title.as_str(), Cell::from(video.title.as_str()),
video.channel.as_deref().unwrap_or("Unknown"), if tagged {
video.publication_date.as_deref().unwrap_or("Unknown"), 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)); 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::<Vec<_>>()
})
.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] #[test]
fn remapped_preview_and_input_actions_survive_key_only_config_reload() { fn remapped_preview_and_input_actions_survive_key_only_config_reload() {
use ratatui::{Terminal, backend::TestBackend}; use ratatui::{Terminal, backend::TestBackend};