From a9fef33e510d16de230fcd3268c4248edc1b69c6 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 21:09:47 +0200 Subject: [PATCH] feat: scope individual and bulk removal to collection views --- README.md | 33 ++- src/config.rs | 2 +- src/keys.rs | 364 +++++++++++++++++---------- src/main.rs | 672 ++++++++++++++++++++++++++++++++++++++++++-------- 4 files changed, 816 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index dc413b9..9e96638 100644 --- a/README.md +++ b/README.md @@ -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` | Browse watch later for the current category, or the combined collection elsewhere | | `f` | In watch later, cycle All → Unwatched → Watched | -| `X` (Shift-X) | Remove only the selected watch-later video (also works in previews) | -| `D` | In watch later, confirm removal of all videos matching the current list/filter | +| `x` | In watch-later or saved-video lists, remove the selected video | +| `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` | 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 | | `r` | Reload configuration and refresh the current view | | `?` | 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. -### 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 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. -`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. -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 diff --git a/src/config.rs b/src/config.rs index d920a59..2b141aa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -670,7 +670,7 @@ mod tests { let directory = TestDirectory::new(); 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 = [ ("keys", key_section), ("settings", "[settings]\npinned_categories = []\n"), diff --git a/src/keys.rs b/src/keys.rs index 402ca78..aa0d5e0 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -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