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
+22 -11
View File
@@ -73,11 +73,10 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin
| `w` | Save the selected video to watch later (also works in previews) | | `w` | Save the selected video to watch later (also works in previews) |
| `W` | Browse watch later for the current category, or the combined collection elsewhere | | `W` | Browse watch later for the current category, or the combined collection elsewhere |
| `f` | In watch later, cycle All → Unwatched → Watched | | `f` | In watch later, cycle All → Unwatched → Watched |
| `X` (Shift-X) | Remove only the selected watch-later video (also works in previews) | | `x` | In watch-later or saved-video lists, remove the selected video |
| `D` | In watch later, confirm removal of all videos matching the current list/filter | | `X` (Shift-X) | In either collection list, confirm bulk removal of the current category/filter subset |
| `v` | Save the selected video to saved videos (also works in previews) | | `v` | Save the selected video to saved videos (also works in previews) |
| `V` | Browse saved videos for the current category, or the combined collection elsewhere | | `V` | Browse saved videos for the current category, or the combined collection elsewhere |
| `x` | Remove only the selected saved video (also works in previews) |
| Tab / Shift-Tab | Switch between visible views | | Tab / Shift-Tab | Switch between visible views |
| `r` | Reload configuration and refresh the current view | | `r` | Reload configuration and refresh the current view |
| `?` | Open / close the scrollable keybinding reference | | `?` | Open / close the scrollable keybinding reference |
@@ -184,12 +183,6 @@ Watch-later-only titles are **magenta** in all video tables; categorized channel
Terebi reports acknowledged playback positions. A video becomes watched when an eligible position reaches **95% of a known positive duration**. Seeking near the end counts; live/non-seekable, unknown-duration, and audio-only inputs do not. Launcher exit or window closure alone never marks a video watched. Watched status is global across categories and remains after removing a video from watch later. Terebi reports acknowledged playback positions. A video becomes watched when an eligible position reaches **95% of a known positive duration**. Seeking near the end counts; live/non-seekable, unknown-duration, and audio-only inputs do not. Launcher exit or window closure alone never marks a video watched. Watched status is global across categories and remains after removing a video from watch later.
### Removal
`X` (Shift-X) immediately removes **only the selected video** from watch later, including from Search, category views, or previews. Other videos, saved-video membership, and watched status remain unchanged. Configure this action with `video.watch_later.remove`; it is separate from the bulk action `view.watch_later.remove`.
`D` snapshots all videos matching the current list and filter, then shows their count, category, and filter. **`y` confirms; `n` or Esc cancels.** Enter does not confirm. Removal deletes those videos from the shared collection, so they disappear from every matching category; watched status is untouched. An empty filtered list has nothing to remove.
### Storage ### Storage
Files are siblings of `config.toml`: Files are siblings of `config.toml`:
@@ -209,11 +202,29 @@ Saved videos is a separate collection from watch later, deduplicated by YouTube
Browse a category with `c`, then use `V` for its saved-video list. From Search, Feed, or a channel view, `V` opens the combined collection. Switching between a category's saved videos and watch later with `V` / `W` retains that category. Both lists share the existing temporary-tab and back/forward history behavior. Browse a category with `c`, then use `V` for its saved-video list. From Search, Feed, or a channel view, `V` opens the combined collection. Switching between a category's saved videos and watch later with `V` / `W` retains that category. Both lists share the existing temporary-tab and back/forward history behavior.
`x` removes **only the selected video** from saved videos, whether in a list or a preview. Removing it never changes watch-later membership or global watched status. There is no watched filter or bulk removal for saved videos. Saved lists have no watched filter. Individual and bulk removal use the shared collection-list controls below.
Saved titles are **green**, taking precedence over watch-later magenta when a video belongs to both collections. Removing it from saved videos restores magenta if it remains in watch later. Channel styling, selected-row highlighting, and watched markers are unchanged. Saved titles are **green**, taking precedence over watch-later magenta when a video belongs to both collections. Removing it from saved videos restores magenta if it remains in watch later. Channel styling, selected-row highlighting, and watched markers are unchanged.
The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure the actions with `video.saved.save`, `view.saved.browse`, and `video.saved.remove`; the live hints and `?` reference reflect overrides. The collection is persisted independently in `saved-videos.toml` beside `config.toml`, using the same atomic, owner-only writes and validation as watch later. Saving repeatedly does not add duplicates. Configure saving and browsing with `video.saved.save` and `view.saved.browse`; the live hints and `?` reference reflect overrides.
## Collection removal
Removal controls apply **only in watch-later and saved-video views**, including previews opened from those lists. Search, Feed, ordinary channel/category views, and their previews do not expose removal actions or hints. Text fields and selectors retain their normal controls.
`x` immediately removes only the selected video from the active collection. `X` snapshots the current list and opens bulk confirmation: watch later uses the current category and All/Unwatched/Watched filter; saved videos uses the current category's entire saved list. An empty list does not open confirmation.
The prompt names the collection, category, count, and applicable filter. **`y` confirms; `n` or Esc cancels.** Enter never confirms. Only the snapshotted IDs are removed, even if membership or watched status changes while confirmation is open.
Removal changes only the active collection. Matching category views update, while the other collection and global watched status remain untouched.
The old collection-specific removal settings are no longer accepted. Replace them with:
```toml
[keys]
view.collection.remove.selected = "x"
view.collection.remove.bulk = "X"
```
## Configurable keybindings ## Configurable keybindings
+1 -1
View File
@@ -670,7 +670,7 @@ mod tests {
let directory = TestDirectory::new(); let directory = TestDirectory::new();
let path = directory.0.join("config.toml"); let path = directory.0.join("config.toml");
let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nvideo.saved.remove = []\n\n[keys.selector]\npin = 'f2' # pin note\n"; let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\nview.collection.remove.selected = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
let sections = [ let sections = [
("keys", key_section), ("keys", key_section),
("settings", "[settings]\npinned_categories = []\n"), ("settings", "[settings]\npinned_categories = []\n"),
+219 -125
View File
@@ -13,6 +13,9 @@ pub enum Context {
Normal, Normal,
Info, Info,
Description, Description,
CollectionNormal,
CollectionInfo,
CollectionDescription,
SearchInput, SearchInput,
SelectorNormal, SelectorNormal,
SelectorInput, SelectorInput,
@@ -26,6 +29,9 @@ const ALL: &[Context] = &[
Context::Normal, Context::Normal,
Context::Info, Context::Info,
Context::Description, Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
Context::SearchInput, Context::SearchInput,
Context::SelectorNormal, Context::SelectorNormal,
Context::SelectorInput, Context::SelectorInput,
@@ -34,7 +40,19 @@ const ALL: &[Context] = &[
Context::TagInput, Context::TagInput,
Context::Help, 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] = &[ const TEXT: &[Context] = &[
Context::SearchInput, Context::SearchInput,
Context::SelectorInput, Context::SelectorInput,
@@ -45,6 +63,9 @@ const NON_TEXT: &[Context] = &[
Context::Normal, Context::Normal,
Context::Info, Context::Info,
Context::Description, Context::Description,
Context::CollectionNormal,
Context::CollectionInfo,
Context::CollectionDescription,
Context::SelectorNormal, Context::SelectorNormal,
Context::TagList, Context::TagList,
Context::Help, Context::Help,
@@ -79,7 +100,7 @@ macro_rules! actions {
actions! { actions! {
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"]; 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, ["?"]; 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"]; 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"]; 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"]; VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"]; InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
InfoDescription, "video.info.description", "Show the video description", 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"]; CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"]; ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", 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"]; 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"]; 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"]; 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"]; 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], ["f"]; WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal, Context::CollectionNormal], ["f"];
WatchLaterRemove, "view.watch_later.remove", "Confirm removal of the current filtered watch-later subset", &[Context::Normal], ["D"]; CollectionRemoveSelected, "view.collection.remove.selected", "Remove the selected video from the current collection (also in its previews)", COLLECTION, ["x"];
WatchLaterRemoveSelected, "video.watch_later.remove", "Remove only the selected watch-later video (also in previews)", VIEW, ["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"]; 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"]; SavedVideoBrowse, "view.saved.browse", "Browse this category's saved videos, or the combined collection elsewhere", &[Context::Normal, Context::CollectionNormal], ["V"];
SavedVideoRemove, "video.saved.remove", "Remove the selected saved video (also in previews)", VIEW, ["x"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"]; 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"]; 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"]; 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"]; TagNew, "tags.new", "Enter a new category tag name", &[Context::TagList], ["n"];
TagSave, "tags.save", "Save category tag changes", &[Context::TagList], ["enter"]; TagSave, "tags.save", "Save category tag changes", &[Context::TagList], ["enter"];
TagCancel, "tags.cancel", "Discard category tag changes and close", &[Context::TagList], ["escape"]; 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"]; 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], ["up", "ctrl+k"]; 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], ["pagedown"]; 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], ["pageup"]; 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"]; 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"]; 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"]; HelpPageNext, "help.page.next", "Scroll help down one page", &[Context::Help], ["pagedown"];
@@ -475,14 +495,6 @@ impl Bindings {
"Browse category watch later; combined elsewhere", "Browse category watch later; combined elsewhere",
), ),
(&[WatchLaterFilter], "Cycle all / unwatched / watched"), (&[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], &[SavedVideoBrowse],
"Browse category saved videos; combined elsewhere", "Browse category saved videos; combined elsewhere",
), ),
],
),
( (
&[SavedVideoRemove], "COLLECTION LISTS",
"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); return Some(first);
} }
if pair(InfoOverview, InfoDescription) && VIEW.contains(&context) { if pair(InfoOverview, InfoDescription) && VIEW.contains(&context) {
return Some(if context == Context::Info { return Some(
if matches!(context, Context::Info | Context::CollectionInfo) {
InfoDescription InfoDescription
} else { } else {
InfoOverview 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); return Some(InfoHide);
} }
if context == Context::Description { if matches!(
context,
Context::Description | Context::CollectionDescription
) {
if pair(DescriptionNext, VideoNext) { if pair(DescriptionNext, VideoNext) {
return Some(DescriptionNext); return Some(DescriptionNext);
} }
@@ -753,6 +795,9 @@ mod tests {
(Context::Normal, Action::InfoOverview), (Context::Normal, Action::InfoOverview),
(Context::Info, Action::InfoDescription), (Context::Info, Action::InfoDescription),
(Context::Description, Action::InfoOverview), (Context::Description, Action::InfoOverview),
(Context::CollectionNormal, Action::InfoOverview),
(Context::CollectionInfo, Action::InfoDescription),
(Context::CollectionDescription, Action::InfoOverview),
] { ] {
assert_eq!( assert_eq!(
press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE), press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE),
@@ -951,8 +996,8 @@ mod tests {
#[test] #[test]
fn aliases_replace_defaults_and_can_disable_actions() { 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 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'video.saved.remove' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = '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!(quoted, bindings);
assert_eq!( assert_eq!(
press( press(
@@ -993,124 +1038,173 @@ mod tests {
} }
#[test] #[test]
fn watch_later_overrides_disable_and_respect_contexts() { fn collection_removal_defaults_are_scoped_and_inherit_view_actions() {
let defaults = Bindings::default(); let bindings = Bindings::default();
for &context in ALL {
for (key, action) in [ for (key, action) in [
('w', Action::WatchLaterBrowse), ('x', Action::CollectionRemoveSelected),
('d', Action::WatchLaterRemove), ('X', Action::CollectionRemoveBulk),
] { ] {
assert_eq!( assert_eq!(
press( press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
&defaults, COLLECTION.contains(&context).then_some(action),
Context::Normal,
KeyCode::Char(key),
KeyModifiers::SHIFT
),
Some(action),
); );
} }
assert_eq!( assert_eq!(
press( press(&bindings, context, KeyCode::Char('D'), KeyModifiers::NONE),
&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,
),
] {
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, None,
); );
} }
} for (ordinary, collection) in [
let help = bindings.help_text().to_string(); (Context::Normal, Context::CollectionNormal),
assert!(help.contains("Alt+w") && help.contains("X") && help.contains("Ctrl+f")); (Context::Info, Context::CollectionInfo),
assert!(help.contains("unbound") && help.contains("WATCH LATER")); (Context::Description, Context::CollectionDescription),
let collision = configured("[keys]\nview.watch_later.browse = 'w'") ] {
.unwrap_err() for &(key, action) in &bindings.contexts[ordinary as usize] {
.to_string(); assert_eq!(
assert!( bindings.resolve(collection, KeyEvent::new(key.code, key.modifiers)),
collision.contains("view.watch_later.browse") Some(action),
&& collision.contains("video.watch_later.save") "{ordinary:?} / {collection:?}: {key:?}",
); );
} }
assert_eq!(
bindings.contexts[collection as usize].len(),
bindings.contexts[ordinary as usize].len() + 2,
);
}
}
#[test] #[test]
fn saved_video_overrides_disable_and_respect_contexts() { fn collection_overrides_aliases_normalize_and_disable() {
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),
);
}
}
let bindings = configured( 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(); .unwrap();
for context in ALL { 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!( assert_eq!(
press(&bindings, *context, KeyCode::Char('v'), KeyModifiers::ALT), press(&bindings, context, code, modifiers),
VIEW.contains(context).then_some(Action::SavedVideoSave), COLLECTION.contains(&context).then_some(action),
); );
}
for key in ['x', 'X'] {
assert_eq!( assert_eq!(
press(&bindings, *context, KeyCode::Char('x'), KeyModifiers::SHIFT), press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
(*context == Context::Normal).then_some(Action::SavedVideoBrowse), None
);
for key in ['v', 'V', 'x'] {
assert_eq!(
press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE),
None,
); );
} }
} }
let collision = configured("[keys]\nview.saved.browse = 'v'") let disabled = configured(
.unwrap_err() "[keys.view.collection.remove]\nselected = []\nbulk = []\n[keys.view]\nrefresh = ['x', 'shift+x']\n",
.to_string(); ).unwrap();
assert!(collision.contains("view.saved.browse") && collision.contains("video.saved.save")); 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] #[test]
+557 -101
View File
File diff suppressed because one or more lines are too long