feat: support multiple video selection

This commit is contained in:
2026-10-22 14:01:47 +02:00
parent acfb3be1e7
commit 2d30120072
12 changed files with 883 additions and 169 deletions
+196 -56
View File
@@ -67,6 +67,202 @@ fn app(args: &[&str]) -> App {
)
}
#[test]
fn playback_checks_follow_ids_across_feed_sort_and_clear_on_replacement() {
let mut app = app(&["tubular"]);
app.view_index = 1;
app.feed_channels = vec!["https://www.youtube.com/@fixture".into()];
finish_feed_fixture(
&mut app,
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Old","timestamp":100},{"id":"rQ_J9WH6CGk","title":"New","timestamp":200}]}"#,
true,
);
let view = &mut app.views[1];
view.selection.select(Some(1));
view.toggle_checked();
view.selection.select(Some(0));
view.toggle_checked();
assert_eq!(
view.playback_videos()
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
);
finish_feed_fixture(
&mut app,
r#"{"entries":[{"id":"l-GaaivVJCE","title":"Newest","timestamp":300}]}"#,
false,
);
let view = &mut app.views[1];
assert_eq!(view.selection.selected(), Some(1));
assert_eq!(
view.playback_videos()
.iter()
.map(|video| video.id.as_str())
.collect::<Vec<_>>(),
["rQ_J9WH6CGk", "jNQXAC9IVRw"],
);
view.reset();
assert!(view.checked.is_empty());
// Old rows remain during refresh, but checks made there cannot leak into replacement data.
view.toggle_checked();
assert!(!view.checked.is_empty());
view.accept_page(
youtube::search_results(r#"{"entries":[{"id":"rQ_J9WH6CGk","title":"Replacement"}]}"#)
.unwrap(),
false,
)
.unwrap();
assert!(view.checked.is_empty());
assert_eq!(view.playback_videos()[0].title, "Replacement");
}
#[test]
fn playback_checks_are_local_to_views_and_previews_and_pruned_with_collections() {
let mut app = app(&["tubular"]);
let videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"},{"id":"rQ_J9WH6CGk","title":"Second","uploader_id":"@creator"}]}"#,
).unwrap().videos;
app.views[0].videos = videos.clone();
app.views[0].selection.select(Some(0));
app.views[0].loaded = true;
app.views[1].loaded = true;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
app.switch_view(1);
assert!(app.views[1].checked.is_empty());
app.switch_view(0);
assert!(app.views[0].checked.contains(&videos[0].id));
app.mode = Mode::Insert;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert_eq!(app.views[0].checked.len(), 1);
app.mode = Mode::Normal;
for preview in [
None,
Some(PreviewMode::Info),
Some(PreviewMode::Description),
] {
app.preview = preview;
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::CONTROL));
assert!(app.views[0].checked.is_empty());
app.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
assert!(app.views[0].checked.contains(&videos[0].id));
}
app.preview = None;
for video in &videos {
app.video_state
.save(CollectionKind::WatchLater, video)
.unwrap();
}
app.browse_collection(CollectionKind::WatchLater);
let index = app.view_index;
app.views[index].selection.select(Some(0));
app.views[index].toggle_checked();
app.views[index].selection.select(Some(1));
app.views[index].toggle_checked();
let removed = app.views[index].videos[1].id.clone();
app.remove_collection();
assert!(!app.views[index].checked.contains(&removed));
assert_eq!(app.views[index].checked.len(), 1);
assert_eq!(app.views[index].playback_videos().len(), 1);
app.views[index].watch_filter = WatchFilter::Watched;
app.rebuild_collections();
assert!(app.views[index].videos.is_empty());
assert!(app.views[index].checked.is_empty());
app.views[index].watch_filter = WatchFilter::All;
app.rebuild_collections();
app.views[index].toggle_checked();
// Refresh is a replacement even for locally rebuilt collection views.
app.load_view();
assert!(app.views[index].checked.is_empty());
assert_eq!(app.views[0].checked.len(), 1);
// An empty category exercises query replacement entirely offline.
app.config.categories.insert("Empty".into(), Vec::new());
let mut view = View::new("Empty".into(), ViewSource::Category("Empty".into()));
view.videos = videos;
view.selection.select(Some(0));
view.toggle_checked();
view.query = "replacement query".into();
app.views.push(view);
app.view_index = app.views.len() - 1;
app.search();
assert!(app.views[app.view_index].checked.is_empty());
assert!(app.job.is_none());
}
#[test]
fn playback_checks_render_independently_and_live_hints_use_configured_keys() {
let mut app = app(&["tubular"]);
let document = "[keys.video.select]\ntoggle = 'f20'\nclear = 'f21'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.config.keys = keys::Bindings::from_toml(document.get("keys")).unwrap();
app.key_hints = KeyHints::new(&app.config.keys);
for context in [
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
] {
for key in ["F20", "F21"] {
assert!(app.key_hints.footer(context).contains(key));
assert!(app.key_hints.preview.iter().all(|hint| hint.contains(key)));
assert!(app.config.keys.help_text().to_string().contains(key));
}
}
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"Checked"},{"id":"rQ_J9WH6CGk","title":"Unchecked"}]}"#,
).unwrap().videos;
for (index, video) in app.views[0].videos.iter_mut().enumerate() {
video.id = format!("offline:{index}");
}
app.views[0].selection.select(Some(0));
app.key(KeyEvent::new(KeyCode::F(20), KeyModifiers::NONE));
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24)).unwrap();
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("[x]"));
assert!(rendered.contains("[ ]"));
assert!(rendered.contains("1 checked"));
app.key(KeyEvent::new(KeyCode::F(21), KeyModifiers::NONE));
assert!(app.views[0].checked.is_empty());
}
#[test]
fn playback_checks_survive_spawn_failure_clear_after_spawn_and_fall_back_to_hover() {
let mut app = app(&["tubular"]);
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#,
).unwrap().videos;
app.views[0].selection.select(Some(1));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.views[0].toggle_checked();
app.views[0].selection.select(Some(0));
app.options.player = "/nonexistent/tubular-test-player".into();
app.play();
assert!(app.player_launch.is_none());
assert!(app.views[0].checked.contains("rQ_J9WH6CGk"));
assert_eq!(app.views[0].playback_videos()[0].id, "rQ_J9WH6CGk");
app.options.player = "/usr/bin/true".into();
app.play();
assert!(app.player_launch.is_some());
assert!(app.views[0].checked.is_empty());
assert_eq!(app.views[0].playback_videos()[0].id, "jNQXAC9IVRw");
app.player_launch = None;
app.play();
assert!(app.player_launch.is_some());
}
#[test]
fn title_viewport_tracks_selected_two_line_rows_and_resize_without_changing_identity() {
let mut app = app(&["tubular"]);
@@ -1849,62 +2045,6 @@ fn collection_contexts_preserve_modal_priorities_and_cancel_bulk_from_each_previ
}
}
#[test]
fn configured_removal_hints_follow_collection_views_and_previews() {
let mut app = app(&["tubular"]);
let document =
"[keys]\nview.collection.remove.selected = 'f8'\nview.collection.remove.bulk = 'f9'\n"
.parse::<toml_edit::DocumentMut>()
.unwrap();
app.apply_config(config::Config {
keys: keys::Bindings::from_toml(document.get("keys")).unwrap(),
..Default::default()
});
app.views[0].videos = youtube::search_results(
r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First","uploader_id":"@creator"}]}"#,
)
.unwrap()
.videos;
app.views[0].loaded = true;
app.views[0].selection.select(Some(0));
app.save_collection(CollectionKind::WatchLater);
app.save_collection(CollectionKind::Saved);
app.remember_description("jNQXAC9IVRw".into(), "Cached description.".into());
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(320, 80)).unwrap();
for kind in [
None,
Some(CollectionKind::WatchLater),
Some(CollectionKind::Saved),
] {
app.switch_view(0);
if let Some(kind) = kind {
app.browse_collection(kind);
}
for preview in [
None,
Some(PreviewMode::Info),
Some(PreviewMode::Description),
] {
app.preview = preview;
terminal
.draw(|frame| app.draw(frame, Duration::ZERO))
.unwrap();
let buffer = terminal.backend().buffer();
let rendered: String = buffer.content.iter().map(|cell| cell.symbol()).collect();
assert_eq!(rendered.contains("F8"), kind.is_some());
assert_eq!(rendered.contains("F9"), kind.is_some());
if preview == Some(PreviewMode::Info) {
let popup = preview_area(buffer.area);
let title: String = (popup.x..popup.right())
.map(|x| buffer[(x, popup.y)].symbol())
.collect();
assert_eq!(title.contains("F8"), kind.is_some());
assert_eq!(title.contains("F9"), kind.is_some());
}
}
}
}
#[test]
fn bulk_removal_snapshots_saved_categories_and_watch_filters_independently() {
let root = std::env::temp_dir().join(format!("tubular-collection-bulk-{}", std::process::id()));