feat: scope individual and bulk removal to collection views

This commit is contained in:
2026-10-11 21:09:47 +02:00
parent 343010ce5c
commit a9fef33e51
4 changed files with 816 additions and 255 deletions
+229 -135
View File
@@ -13,6 +13,9 @@ pub enum Context {
Normal,
Info,
Description,
CollectionNormal,
CollectionInfo,
CollectionDescription,
SearchInput,
SelectorNormal,
SelectorInput,
@@ -26,6 +29,9 @@ const ALL: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
Context::SearchInput,
Context::SelectorNormal,
Context::SelectorInput,
@@ -34,7 +40,19 @@ const ALL: &[Context] = &[
Context::TagInput,
Context::Help,
];
const VIEW: &[Context] = &[Context::Normal, Context::Info, Context::Description];
const VIEW: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
];
const COLLECTION: &[Context] = &[
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
];
const TEXT: &[Context] = &[
Context::SearchInput,
Context::SelectorInput,
@@ -45,6 +63,9 @@ const NON_TEXT: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
Context::SelectorNormal,
Context::TagList,
Context::Help,
@@ -79,7 +100,7 @@ macro_rules! actions {
actions! {
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::Help], ["q"];
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"];
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter or stage new tag", TEXT, ["enter"];
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
@@ -99,20 +120,19 @@ actions! {
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description], ["escape"];
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description, Context::CollectionInfo, Context::CollectionDescription], ["escape"];
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"];
WatchLaterBrowse, "view.watch_later.browse", "Browse this category's watch later, or the combined collection elsewhere", &[Context::Normal], ["W"];
WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal], ["f"];
WatchLaterRemove, "view.watch_later.remove", "Confirm removal of the current filtered watch-later subset", &[Context::Normal], ["D"];
WatchLaterRemoveSelected, "video.watch_later.remove", "Remove only the selected watch-later video (also in previews)", VIEW, ["X"];
WatchLaterBrowse, "view.watch_later.browse", "Browse this category's watch later, or the combined collection elsewhere", &[Context::Normal, Context::CollectionNormal], ["W"];
WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal, Context::CollectionNormal], ["f"];
CollectionRemoveSelected, "view.collection.remove.selected", "Remove the selected video from the current collection (also in its previews)", COLLECTION, ["x"];
CollectionRemoveBulk, "view.collection.remove.bulk", "Confirm removal of the current collection subset", COLLECTION, ["X"];
SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"];
SavedVideoBrowse, "view.saved.browse", "Browse this category's saved videos, or the combined collection elsewhere", &[Context::Normal], ["V"];
SavedVideoRemove, "video.saved.remove", "Remove the selected saved video (also in previews)", VIEW, ["x"];
SavedVideoBrowse, "view.saved.browse", "Browse this category's saved videos, or the combined collection elsewhere", &[Context::Normal, Context::CollectionNormal], ["V"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
@@ -130,10 +150,10 @@ actions! {
TagNew, "tags.new", "Enter a new category tag name", &[Context::TagList], ["n"];
TagSave, "tags.save", "Save category tag changes", &[Context::TagList], ["enter"];
TagCancel, "tags.cancel", "Discard category tag changes and close", &[Context::TagList], ["escape"];
DescriptionNext, "video.description.scroll.next", "Scroll the description down one line", &[Context::Description], ["down", "ctrl+j"];
DescriptionPrevious, "video.description.scroll.previous", "Scroll the description up one line", &[Context::Description], ["up", "ctrl+k"];
DescriptionPageNext, "video.description.page.next", "Scroll the description down one page", &[Context::Description], ["pagedown"];
DescriptionPagePrevious, "video.description.page.previous", "Scroll the description up one page", &[Context::Description], ["pageup"];
DescriptionNext, "video.description.scroll.next", "Scroll the description down one line", &[Context::Description, Context::CollectionDescription], ["down", "ctrl+j"];
DescriptionPrevious, "video.description.scroll.previous", "Scroll the description up one line", &[Context::Description, Context::CollectionDescription], ["up", "ctrl+k"];
DescriptionPageNext, "video.description.page.next", "Scroll the description down one page", &[Context::Description, Context::CollectionDescription], ["pagedown"];
DescriptionPagePrevious, "video.description.page.previous", "Scroll the description up one page", &[Context::Description, Context::CollectionDescription], ["pageup"];
HelpNext, "help.scroll.next", "Scroll help down one line", &[Context::Help], ["j", "down", "ctrl+j"];
HelpPrevious, "help.scroll.previous", "Scroll help up one line", &[Context::Help], ["k", "up", "ctrl+k"];
HelpPageNext, "help.page.next", "Scroll help down one page", &[Context::Help], ["pagedown"];
@@ -475,14 +495,6 @@ impl Bindings {
"Browse category watch later; combined elsewhere",
),
(&[WatchLaterFilter], "Cycle all / unwatched / watched"),
(
&[WatchLaterRemoveSelected],
"Remove the selected video (also in previews)",
),
(
&[WatchLaterRemove],
"Confirm removal of filtered subset (combined: all matches)",
),
],
),
(
@@ -496,9 +508,26 @@ impl Bindings {
&[SavedVideoBrowse],
"Browse category saved videos; combined elsewhere",
),
],
),
(
"COLLECTION LISTS",
&[
(
&[SavedVideoRemove],
"Remove the selected video (also in previews)",
&[CollectionRemoveSelected],
"Remove selected from current watch-later / saved list only",
),
(
&[CollectionRemoveBulk],
"Confirm removal of current category / filtered subset",
),
(
&[],
"Previews inherit their underlying collection's removal scope",
),
(
&[],
"Bulk confirmation: y / Enter confirms; n / Esc cancels",
),
],
),
@@ -658,16 +687,29 @@ fn overlap_winner(context: Context, first: Action, second: Action) -> Option<Act
return Some(first);
}
if pair(InfoOverview, InfoDescription) && VIEW.contains(&context) {
return Some(if context == Context::Info {
InfoDescription
} else {
InfoOverview
});
return Some(
if matches!(context, Context::Info | Context::CollectionInfo) {
InfoDescription
} else {
InfoOverview
},
);
}
if pair(InfoHide, ViewPrevious) && matches!(context, Context::Info | Context::Description) {
if pair(InfoHide, ViewPrevious)
&& matches!(
context,
Context::Info
| Context::Description
| Context::CollectionInfo
| Context::CollectionDescription
)
{
return Some(InfoHide);
}
if context == Context::Description {
if matches!(
context,
Context::Description | Context::CollectionDescription
) {
if pair(DescriptionNext, VideoNext) {
return Some(DescriptionNext);
}
@@ -753,6 +795,9 @@ mod tests {
(Context::Normal, Action::InfoOverview),
(Context::Info, Action::InfoDescription),
(Context::Description, Action::InfoOverview),
(Context::CollectionNormal, Action::InfoOverview),
(Context::CollectionInfo, Action::InfoDescription),
(Context::CollectionDescription, Action::InfoOverview),
] {
assert_eq!(
press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE),
@@ -951,8 +996,8 @@ mod tests {
#[test]
fn aliases_replace_defaults_and_can_disable_actions() {
let bindings = configured("[keys.video.saved]\nremove = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap();
let quoted = configured("[keys]\n'video.saved.remove' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap();
let bindings = configured("[keys.view.collection.remove]\nselected = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap();
let quoted = configured("[keys]\n'view.collection.remove.selected' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap();
assert_eq!(quoted, bindings);
assert_eq!(
press(
@@ -993,124 +1038,173 @@ mod tests {
}
#[test]
fn watch_later_overrides_disable_and_respect_contexts() {
let defaults = Bindings::default();
for (key, action) in [
('w', Action::WatchLaterBrowse),
('d', Action::WatchLaterRemove),
] {
assert_eq!(
press(
&defaults,
Context::Normal,
KeyCode::Char(key),
KeyModifiers::SHIFT
),
Some(action),
);
}
assert_eq!(
press(
&defaults,
Context::Info,
KeyCode::Char('w'),
KeyModifiers::NONE
),
Some(Action::WatchLaterSave),
);
assert_eq!(
press(
&defaults,
Context::Info,
KeyCode::Char('d'),
KeyModifiers::NONE
),
Some(Action::InfoDescription),
);
let bindings = configured(
"[keys.video.watch_later]\nsave = 'alt+w'\nremove = []\n[keys.view.watch_later]\nbrowse = 'shift+x'\nfilter = 'ctrl+f'\nremove = []\n",
)
.unwrap();
for context in ALL {
assert_eq!(
press(&bindings, *context, KeyCode::Char('w'), KeyModifiers::ALT),
VIEW.contains(context).then_some(Action::WatchLaterSave),
);
for (code, modifiers, action) in [
(
KeyCode::Char('x'),
KeyModifiers::SHIFT,
Action::WatchLaterBrowse,
),
(
KeyCode::Char('f'),
KeyModifiers::CONTROL,
Action::WatchLaterFilter,
),
fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
let bindings = Bindings::default();
for &context in ALL {
for (key, action) in [
('x', Action::CollectionRemoveSelected),
('X', Action::CollectionRemoveBulk),
] {
assert_eq!(
press(&bindings, *context, code, modifiers),
(*context == Context::Normal).then_some(action),
);
}
for key in ['w', 'W', 'f', 'D'] {
assert_eq!(
press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE),
None,
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
COLLECTION.contains(&context).then_some(action),
);
}
assert_eq!(
press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE),
None,
);
}
for (ordinary, collection) in [
(Context::Normal, Context::CollectionNormal),
(Context::Info, Context::CollectionInfo),
(Context::Description, Context::CollectionDescription),
] {
for &(key, action) in &bindings.contexts[ordinary as usize] {
assert_eq!(
bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
Some(action),
"{ordinary:?} / {collection:?}: {key:?}",
);
}
assert_eq!(
bindings.contexts[collection as usize].len(),
bindings.contexts[ordinary as usize].len() + 2,
);
}
let help = bindings.help_text().to_string();
assert!(help.contains("Alt+w") && help.contains("X") && help.contains("Ctrl+f"));
assert!(help.contains("unbound") && help.contains("WATCH LATER"));
let collision = configured("[keys]\nview.watch_later.browse = 'w'")
.unwrap_err()
.to_string();
assert!(
collision.contains("view.watch_later.browse")
&& collision.contains("video.watch_later.save")
);
}
#[test]
fn saved_video_overrides_disable_and_respect_contexts() {
let defaults = Bindings::default();
for context in ALL {
for (key, action, enabled) in [
('v', Action::SavedVideoSave, VIEW.contains(context)),
('V', Action::SavedVideoBrowse, *context == Context::Normal),
('x', Action::SavedVideoRemove, VIEW.contains(context)),
] {
assert_eq!(
press(&defaults, *context, KeyCode::Char(key), KeyModifiers::NONE),
enabled.then_some(action),
);
}
}
fn collection_overrides_aliases_normalize_and_disable() {
let bindings = configured(
"[keys.video.watch_later]\nremove = []\n[keys.video.saved]\nsave = 'alt+v'\nremove = []\n[keys.view.saved]\nbrowse = 'shift+x'\n",
"[keys.view.collection.remove]\nselected = ['option+x', 'alt+x']\nbulk = 'shift+b'\n",
)
.unwrap();
for context in ALL {
assert_eq!(
press(&bindings, *context, KeyCode::Char('v'), KeyModifiers::ALT),
VIEW.contains(context).then_some(Action::SavedVideoSave),
);
assert_eq!(
press(&bindings, *context, KeyCode::Char('x'), KeyModifiers::SHIFT),
(*context == Context::Normal).then_some(Action::SavedVideoBrowse),
);
for key in ['v', 'V', 'x'] {
let quoted = configured(
"[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n",
).unwrap();
assert_eq!(bindings, quoted);
assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x");
assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B");
let help = bindings.help_text().to_string();
assert!(help.contains("Alt+x"));
assert!(help.lines().any(|line| line.trim_start().starts_with("B ")));
for &context in ALL {
for (code, modifiers, action) in [
(
KeyCode::Char('x'),
KeyModifiers::ALT,
Action::CollectionRemoveSelected,
),
(
KeyCode::Char('b'),
KeyModifiers::SHIFT,
Action::CollectionRemoveBulk,
),
(
KeyCode::Char('B'),
KeyModifiers::NONE,
Action::CollectionRemoveBulk,
),
] {
assert_eq!(
press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE),
None,
press(&bindings, context, code, modifiers),
COLLECTION.contains(&context).then_some(action),
);
}
for key in ['x', 'X'] {
assert_eq!(
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
None
);
}
}
let collision = configured("[keys]\nview.saved.browse = 'v'")
.unwrap_err()
.to_string();
assert!(collision.contains("view.saved.browse") && collision.contains("video.saved.save"));
let disabled = configured(
"[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n",
).unwrap();
for &context in ALL {
for key in ['x', 'X'] {
assert_eq!(
press(&disabled, context, KeyCode::Char(key), KeyModifiers::NONE),
VIEW.contains(&context).then_some(Action::ViewRefresh),
);
}
}
}
#[test]
fn collection_contexts_validate_inherited_binding_conflicts() {
for source in [
"[keys]\nview.refresh = 'x'",
"[keys]\nview.watch_later.browse = 'X'",
"[keys]\nview.saved.browse = 'X'",
"[keys]\nview.collection.remove.selected = 'q'",
"[keys]\nview.collection.remove.bulk = 'down'",
] {
let error = configured(source).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("Collection"), "{error}");
}
}
#[test]
fn collection_browsing_and_save_overrides_retain_contexts() {
let bindings = configured(
"[keys.video.watch_later]\nsave = 'alt+w'\n[keys.video.saved]\nsave = 'alt+v'\n[keys.view.watch_later]\nbrowse = 'shift+x'\nfilter = 'ctrl+f'\n[keys.view.saved]\nbrowse = 'alt+b'\n[keys.view.collection.remove]\nbulk = []\n",
).unwrap();
for &context in ALL {
for (key, modifiers, action, enabled) in [
(
'w',
KeyModifiers::ALT,
Action::WatchLaterSave,
VIEW.contains(&context),
),
(
'v',
KeyModifiers::ALT,
Action::SavedVideoSave,
VIEW.contains(&context),
),
(
'x',
KeyModifiers::SHIFT,
Action::WatchLaterBrowse,
matches!(context, Context::Normal | Context::CollectionNormal),
),
(
'f',
KeyModifiers::CONTROL,
Action::WatchLaterFilter,
matches!(context, Context::Normal | Context::CollectionNormal),
),
(
'b',
KeyModifiers::ALT,
Action::SavedVideoBrowse,
matches!(context, Context::Normal | Context::CollectionNormal),
),
] {
assert_eq!(
press(&bindings, context, KeyCode::Char(key), modifiers),
enabled.then_some(action)
);
}
}
}
#[test]
fn obsolete_removal_names_are_unknown() {
for name in [
"view.watch_later.remove",
"video.watch_later.remove",
"video.saved.remove",
] {
let error = configured(&format!("[keys]\n'{name}' = []")).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidData);
assert!(error.to_string().contains("unknown action"), "{error}");
assert!(error.to_string().contains(name), "{error}");
}
}
#[test]