feat: scope individual and bulk removal to collection views
This commit is contained in:
@@ -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
@@ -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"),
|
||||||
|
|||||||
+229
-135
@@ -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",
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"COLLECTION LISTS",
|
||||||
|
&[
|
||||||
(
|
(
|
||||||
&[SavedVideoRemove],
|
&[CollectionRemoveSelected],
|
||||||
"Remove the selected video (also in previews)",
|
"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(
|
||||||
InfoDescription
|
if matches!(context, Context::Info | Context::CollectionInfo) {
|
||||||
} else {
|
InfoDescription
|
||||||
InfoOverview
|
} 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);
|
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 (key, action) in [
|
for &context in ALL {
|
||||||
('w', Action::WatchLaterBrowse),
|
for (key, action) in [
|
||||||
('d', Action::WatchLaterRemove),
|
('x', Action::CollectionRemoveSelected),
|
||||||
] {
|
('X', Action::CollectionRemoveBulk),
|
||||||
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,
|
|
||||||
),
|
|
||||||
] {
|
] {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
press(&bindings, *context, code, modifiers),
|
press(&bindings, context, KeyCode::Char(key), KeyModifiers::NONE),
|
||||||
(*context == Context::Normal).then_some(action),
|
COLLECTION.contains(&context).then_some(action),
|
||||||
);
|
|
||||||
}
|
|
||||||
for key in ['w', 'W', 'f', 'D'] {
|
|
||||||
assert_eq!(
|
|
||||||
press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE),
|
|
||||||
None,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
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]
|
#[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(
|
||||||
assert_eq!(
|
"[keys]\n'view.collection.remove.selected' = 'alt+x'\n'view.collection.remove.bulk' = 'B'\n",
|
||||||
press(&bindings, *context, KeyCode::Char('v'), KeyModifiers::ALT),
|
).unwrap();
|
||||||
VIEW.contains(context).then_some(Action::SavedVideoSave),
|
assert_eq!(bindings, quoted);
|
||||||
);
|
assert_eq!(bindings.label(Action::CollectionRemoveSelected), "Alt+x");
|
||||||
assert_eq!(
|
assert_eq!(bindings.label(Action::CollectionRemoveBulk), "B");
|
||||||
press(&bindings, *context, KeyCode::Char('x'), KeyModifiers::SHIFT),
|
let help = bindings.help_text().to_string();
|
||||||
(*context == Context::Normal).then_some(Action::SavedVideoBrowse),
|
assert!(help.contains("Alt+x"));
|
||||||
);
|
assert!(help.lines().any(|line| line.trim_start().starts_with("B ")));
|
||||||
for key in ['v', 'V', 'x'] {
|
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(key), KeyModifiers::NONE),
|
press(&bindings, context, code, modifiers),
|
||||||
None,
|
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'")
|
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]
|
||||||
|
|||||||
+564
-108
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user