feat: color category-saved channel names
This commit is contained in:
@@ -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
@@ -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};
|
||||||
|
|||||||
Reference in New Issue
Block a user