From ff7590dfa77a3f92e548d19e2ec1c206aa8f72ec Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 23:10:15 +0200 Subject: [PATCH] feat: rename categories from views and selectors --- README.md | 25 +- src/config.rs | 212 +++++++++++++ src/keys.rs | 25 +- src/main.rs | 822 +++++++++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 1065 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 0da3de6..e7a6c5d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,7 @@ These are the default bindings. Overrides in `[keys]` replace an action's bindin | `U` (Shift-U) | Open the selected video's channel | | `s` | Edit the selected channel's tags | | `S` | Edit tags for all saved channels in the current category | +| `R` (Shift-R) | Rename the current category, including category-scoped collection views | | `w` | Save the selected video to watch later (also works in previews) | | `W` | Choose combined or category watch later from any video view or preview | | `Alt-w` | Open the current category's watch later | @@ -95,12 +96,12 @@ The full-description view scrolls with ↓ / ↑, Ctrl-J / Ctrl-K, or PageDown / ### Text input -These shortcuts work in search, category/channel/tag-filter, and new-tag fields: +These shortcuts work in search, category/channel/tag-filter, new-tag, and category-rename fields: | Key | Action | | --- | --- | -| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, or stage the new tag | -| Esc | Leave insert mode or abandon the new-tag name | +| Enter | Submit the search, browse the selected category/channel, leave tag-filter mode, stage a new tag, or commit a rename | +| Esc | Leave insert mode or abandon the new-tag / rename prompt | | Backspace | Delete one character | | Option/Alt-Backspace or Ctrl-W | Delete the previous whitespace-delimited word | | Cmd/Super-Backspace or Ctrl-U | Clear the line | @@ -111,9 +112,9 @@ In terminals supporting bracketed paste, clipboard text is inserted in one opera ### Category browser (`c`) -- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; Esc closes. +- **Normal:** `j` / `k` or arrow keys navigate; `i` enters filter mode; Enter browses the selected category; `p` toggles its persisted pin; `R` renames it; Esc closes. - **Insert:** type a case-insensitive substring to filter; arrow keys or Ctrl-J / Ctrl-K navigate matches; Enter browses; Esc returns to normal mode without clearing the filter. -- Reopening the browser clears its filter. No matches makes browsing and pinning a no-op. +- Reopening the browser clears its filter. No matches makes browsing, pinning, and renaming a no-op. ### Saved channels (`u`) @@ -127,15 +128,25 @@ Press **`U` (Shift-U)** on a video to browse its creator directly, including cha ### Channel tags (`s`, or `S` for bulk editing) -- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter. +- **Normal:** `j` / `k` or arrow keys navigate; Space toggles a tag; `i` edits the filter; `R` renames the selected tag. - **Insert:** type a case-insensitive substring to filter live; arrow keys or Ctrl-J / Ctrl-K navigate matches. Enter or Esc returns to normal mode without saving or clearing the filter. - Filtering only hides rows: checked and unchecked staged memberships outside the filter are retained. With no matches, navigation and toggling do nothing. Reopening the dialog clears its filter. - `n` prompts for a new tag name. Enter stages it checked, clears the filter, and selects the new tag; Esc abandons the name. -- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels without writing. These controls apply to both single-channel and bulk tagging. +- Enter **in the normal-mode tag list** saves all staged memberships, including hidden tags. Esc cancels those staged changes; it does not undo a committed rename of an existing category. These controls apply to both single-channel and bulk tagging. - Filter actions are configurable as `tags.edit`, `tags.input.next`, and `tags.input.previous`; text editing uses the shared `input.*` actions. Bulk editing applies the exact checked tag set to **every saved channel in the current category**, not only channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Review the checked set before saving: unchecked tags are removed from all targets. Other channels, saved registrations, empty categories, pins, and comments are preserved. +### Renaming categories (`R`) + +In normal mode, press `R` (Shift-R) to rename the active category, or the hovered category in a category/collection selector or single-channel/bulk tag list. Combined collection entries and channel rows cannot be renamed. + +The floating prompt starts with the current name. Edit it with the shared text controls (`Ctrl-U` clears it), then press Enter to commit or Esc to cancel. Names are trimmed and must be nonempty, free of control characters, and distinct from existing or staged category names. Errors leave the prompt open without changing the file. If the source category changed externally, close dialogs and reload config with `r` before retrying. + +Renaming an existing category saves its identity immediately while preserving channel memberships, pins, comments, cached videos, selection, pagination, watch filters, and back/forward history. In tag dialogs, checkbox changes remain staged until the main tag-list Enter; cancelling them does not undo the rename. A newly staged, unsaved tag stays local when renamed and reaches disk only when the tags are saved. + +Configure the shortcut with `category.rename`; the prompt uses the shared `input.*` actions. `R` remains ordinary text while editing search or filters. + ## Subscriptions and feeds Configuration lives at: diff --git a/src/config.rs b/src/config.rs index 2b141aa..4d12248 100644 --- a/src/config.rs +++ b/src/config.rs @@ -384,6 +384,83 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result< }) } +pub fn rename_category(path: &Path, old: &str, new: &str) -> io::Result { + let old = category_name(old)?; + let new = category_name(new)?; + let (original, mut document) = read_document(path)?; + let channels = parse_channels(&document)?; + let categories = parse_categories(&document, &channels)?; + let pinned_categories = parse_pins(&document)?; + let keys = keys::Bindings::from_toml(document.get("keys"))?; + if !categories.contains_key(old) { + return Err(invalid(format!("Unknown source category: {old:?}"))); + } + if old == new { + return Ok(Config { + channels, + categories, + pinned_categories, + keys, + }); + } + if categories.contains_key(new) { + return Err(invalid(format!("Category already exists: {new:?}"))); + } + + // KeyMut only edits formatting. Move entries through formatted insertion to + // rename the key without changing its position or any membership formatting. + match &mut document["categories"] { + Item::Table(table) => { + let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect(); + for name in names { + let (mut key, item) = table.remove_entry(&name).expect("category was validated"); + if key.get().trim() == old { + key = toml_edit::Key::new(new) + .with_leaf_decor(key.leaf_decor().clone()) + .with_dotted_decor(key.dotted_decor().clone()); + } + table.insert_formatted(&key, item); + } + } + Item::Value(toml_edit::Value::InlineTable(table)) => { + let names: Vec<_> = table.iter().map(|(name, _)| name.to_owned()).collect(); + for name in names { + let (mut key, item) = table.remove_entry(&name).expect("category was validated"); + if key.get().trim() == old { + key = toml_edit::Key::new(new) + .with_leaf_decor(key.leaf_decor().clone()) + .with_dotted_decor(key.dotted_decor().clone()); + } + table.insert_formatted(&key, item); + } + } + _ => unreachable!("categories were validated"), + } + if let Some(array) = document + .get_mut("settings") + .and_then(Item::as_table_like_mut) + .and_then(|settings| settings.get_mut("pinned_categories")) + .and_then(Item::as_array_mut) + { + for pin in array.iter_mut() { + if pin.as_str().expect("pin was validated").trim() == old { + let decor = pin.decor().clone(); + *pin = new.into(); + *pin.decor_mut() = decor; + } + } + } + let categories = parse_categories(&document, &channels)?; + let pinned_categories = parse_pins(&document)?; + save(path, original.as_deref(), document.to_string().as_bytes())?; + Ok(Config { + channels, + categories, + pinned_categories, + keys, + }) +} + fn parse_pins(document: &DocumentMut) -> io::Result> { if document.contains_key("pinned_categories") { return Err(invalid("Move top-level pinned_categories into [settings]")); @@ -912,6 +989,141 @@ mod tests { } } + #[test] + fn category_rename_preserves_memberships_pins_comments_and_section_order() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "# manual preferences\n[other]\nvalue = 42\n[settings]\npinned_categories = [\n ' Source ', # favorite\n 'Keep',\n] # pin note\nplayer = 'mpv'\n[keys.app]\nquit = ['q'] # key note\n[channels]\n'@mapped' = 'UC4QobU6STFB0P71PMvOGN5A' # mapping note\n[categories]\nBefore = []\n# category note\n' Source ' = [\n '@mapped', # entry note\n '@manual',\n '@manual',\n] # array note\nKeep = ['@manual']\n"; + fs::write(&path, original).unwrap(); + let before = load(&path).unwrap(); + let before_document = original.parse::().unwrap(); + let renamed = rename_category(&path, " Source ", " Renamed group ").unwrap(); + assert!(!renamed.categories.contains_key("Source")); + assert_eq!( + renamed.categories["Renamed group"], + before.categories["Source"] + ); + assert_eq!(renamed.categories["Keep"], before.categories["Keep"]); + assert_eq!(renamed.channels, before.channels); + assert_eq!(renamed.subscriptions(), before.subscriptions()); + assert_eq!(renamed.pinned_categories, ["Renamed group", "Keep"]); + let contents = fs::read_to_string(&path).unwrap(); + let document = contents.parse::().unwrap(); + assert_eq!( + document.iter().map(|(name, _)| name).collect::>(), + ["other", "settings", "keys", "channels", "categories"] + ); + assert_eq!( + document["categories"] + .as_table_like() + .unwrap() + .iter() + .map(|(name, _)| name) + .collect::>(), + ["Before", "Renamed group", "Keep"] + ); + assert_eq!( + document["categories"]["Renamed group"].to_string(), + before_document["categories"][" Source "].to_string() + ); + for section in ["other", "keys", "channels"] { + assert_eq!( + document[section].to_string(), + before_document[section].to_string() + ); + } + assert_eq!(document["settings"]["player"].as_str(), Some("mpv")); + for comment in [ + "# manual preferences", + "# favorite", + "# pin note", + "# category note", + "# entry note", + "# array note", + "# key note", + "# mapping note", + ] { + assert!(contents.contains(comment), "lost {comment}"); + } + assert_eq!(load(&path).unwrap().categories, renamed.categories); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + + #[test] + fn category_rename_preserves_inline_tables_and_reads_latest_memberships() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "categories = { Before = [], Source = ['@first', '@first'], 'Keep' = [] } # categories note\nsettings = { pinned_categories = ['Source'], custom = 42 }\n"; + fs::write(&path, original).unwrap(); + let opened = load(&path).unwrap(); + let latest = original.replace("'@first', '@first'", "'@first', '@late', '@first'"); + fs::write(&path, &latest).unwrap(); + let renamed = rename_category(&path, "Source", "Renamed").unwrap(); + assert_eq!(opened.categories["Source"].len(), 1); + assert_eq!( + renamed.categories["Renamed"], + [ + "https://www.youtube.com/@first", + "https://www.youtube.com/@late", + ] + ); + assert!(!renamed.categories.contains_key("Source")); + assert_eq!(renamed.pinned_categories, ["Renamed"]); + let contents = fs::read_to_string(&path).unwrap(); + assert_eq!( + contents, + latest + .replace("Source =", "Renamed =") + .replace("'Source'", "\"Renamed\"") + ); + let document = contents.parse::().unwrap(); + assert!(document["categories"].is_inline_table()); + assert_eq!(document["settings"]["custom"].as_integer(), Some(42)); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + + #[test] + fn category_rename_rejects_invalid_missing_or_conflicting_names_without_writes() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let original = "[settings]\npinned_categories = ['Source', 'Keep']\n[categories]\n' Source ' = ['@manual']\nKeep = []\n"; + fs::write(&path, original).unwrap(); + for (old, new) in [ + ("Source", ""), + ("Source", " \t "), + ("Source", "Bad\nName"), + ("", "New"), + ("Missing", "New"), + ("Missing", "Missing"), + ("Source", "Keep"), + ("Source", " Keep "), + ] { + assert!( + rename_category(&path, old, new).is_err(), + "{old:?} -> {new:?}" + ); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + } + let unchanged = rename_category(&path, " Source ", "Source").unwrap(); + assert_eq!(unchanged.categories, load(&path).unwrap().categories); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + for invalid in [ + "[settings]\npinned_categories = [7]\n[categories]\nSource = []\n", + "[settings]\npinned_categories = ['Source', 'New']\n[categories]\nSource = []\n", + "[keys.global]\nunknown = ['q']\n[categories]\nSource = []\n", + "[categories]\nSource = [7]\n", + "[categories]\nSource = []\n' Source ' = []\n", + ] { + fs::write(&path, invalid).unwrap(); + assert!(rename_category(&path, "Source", "New").is_err()); + assert_eq!(fs::read(&path).unwrap(), invalid.as_bytes()); + } + let missing = directory.0.join("missing/config.toml"); + assert!(rename_category(&missing, "Source", "New").is_err()); + assert!(!missing.parent().unwrap().exists()); + assert_eq!(fs::read_dir(&directory.0).unwrap().count(), 1); + } + #[test] fn category_tags_apply_exact_set_and_preserve_unrelated_entries_and_preferences() { let directory = TestDirectory::new(); diff --git a/src/keys.rs b/src/keys.rs index 2efd927..b00049b 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -22,6 +22,7 @@ pub enum Context { TagList, TagFilter, TagInput, + RenameInput, Help, } @@ -38,6 +39,7 @@ const ALL: &[Context] = &[ Context::TagList, Context::TagFilter, Context::TagInput, + Context::RenameInput, Context::Help, ]; const VIEW: &[Context] = &[ @@ -58,6 +60,7 @@ const TEXT: &[Context] = &[ Context::SelectorInput, Context::TagFilter, Context::TagInput, + Context::RenameInput, ]; const NON_TEXT: &[Context] = &[ Context::Normal, @@ -102,7 +105,7 @@ actions! { Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"]; 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"]; + InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or rename category", TEXT, ["enter"]; InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"]; DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"]; DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"]; @@ -126,6 +129,7 @@ actions! { 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"]; + CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"]; WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"]; WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"]; WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"]; @@ -478,6 +482,10 @@ impl Bindings { (&[CategoryBrowse], "Choose a saved category"), (&[ChannelTags], "Edit the selected channel's tags"), (&[CategoryTags], "Edit all channels in a category"), + ( + &[CategoryRename], + "Rename the current category (including scoped collections)", + ), (&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"), (&[ViewPrevious], "Back (preview close takes priority)"), (&[ViewNext], "Forward after going back"), @@ -568,6 +576,10 @@ impl Bindings { &[SelectorPin], "Pin / unpin a category (category picker only)", ), + ( + &[CategoryRename], + "Rename the selected category, not combined / channel rows", + ), (&[InputCancel, SelectorClose], "INSERT → NORMAL → close"), ], ), @@ -583,6 +595,10 @@ impl Bindings { ), (&[TagToggle], "Toggle a tag"), (&[TagNew], "Enter a new tag name"), + ( + &[CategoryRename], + "Rename a tag; existing category renames save immediately", + ), (&[TagSave], "Save tags"), (&[InputSubmit], "Stage a new tag name"), (&[TagCancel], "Cancel tag changes"), @@ -592,8 +608,11 @@ impl Bindings { ( "TEXT INPUT", &[ - (&[InputSubmit], "Submit; search returns to NORMAL"), - (&[InputCancel], "Leave INSERT / abandon tag name"), + ( + &[InputSubmit], + "Submit search / stage tag / commit category rename", + ), + (&[InputCancel], "Leave INSERT / abandon tag name or rename"), (&[DeleteCharacter], "Delete one character"), (&[DeleteWord], "Delete previous word"), (&[DeleteLine], "Clear the line"), diff --git a/src/main.rs b/src/main.rs index 033f88f..cb6ed4e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -139,7 +139,7 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube and open selected videos in Terebi", - after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nRequires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file for an independent private reporting stream and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category/channel filters, and new tag): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d opens video info (again for full description); c browses saved categories; u browses saved channels; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. Watch-later titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\nSAVED VIDEOS: v saves the selected video once (also in previews); V opens a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; Esc or - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter browses; p immediately saves pin/unpin; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses the selected match; Esc returns to NORMAL with the filter retained. In NORMAL, j/k and pinning operate on the filtered list. No matches makes Enter/p a no-op. Reopening the selector clears the filter.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nChannels and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\nHidden category and channel views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; Esc cancels without writing.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nNEW TAG: type a name; Enter stages it checked, clears the filter, selects the new tag, and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" + after_help = "Key names below describe default bindings. [keys] overrides named actions with a string or array; omitted actions keep defaults and [] disables an action. Live hints and help reflect overrides.\n--default-config prints complete default TOML to stdout and exits without loading config or starting a terminal.\nRequires yt-dlp for browsing; thumbnails also use curl. Playback requires Terebi and its own streaming dependencies.\nDefaults to terebi on PATH; --player overrides the CLI path.\nTerebi receives --new-instance --url followed by the canonical YouTube video URL plus --progress-file for an independent private reporting stream and handles extraction, decoding, and playback itself; each selection opens an independent player instance without replacing existing playback.\nAlways start in NORMAL. A CLI query is searched automatically; otherwise press i to enter a query.\nNORMAL ? opens a scrollable keybinding reference over the current view or dialog. The bottom bar shows only essential actions. Scroll help with j/k, arrows, Ctrl-J/K or PageUp/PageDown; ? or Esc closes it. Question mark remains literal text in input fields.\nINSERT: type to edit; Enter submits if changed and returns to NORMAL; Esc returns without submitting.\nTEXT EDITING (search, category/channel filters, new tag, and category rename): Option/Alt+Backspace or Ctrl-W deletes the previous whitespace-delimited word, including trailing whitespace; Cmd/Super+Backspace or Ctrl-U clears the entire line. Plain Backspace removes one character.\nThe terminal must transmit modified Backspace. If it intercepts Option/Cmd, map those shortcuts to Ctrl-W/Ctrl-U respectively.\nNORMAL: j/k or arrows select; Enter opens the video in Terebi; i edits the query; d opens video info (again for full description); c browses saved categories; u browses saved channels; U opens the selected video's channel; s edits channel tags; S edits all channels in the current category; R renames the current explicit category; q quits.\nWATCH LATER: w saves the selected video once (also in previews); W opens a searchable watch-later collection selector; Alt-w opens the current explicit category's watch-later list. Both work in previews. Moving between watch-later scopes preserves the watched filter. Lists follow current channel category memberships and use persisted metadata offline. Only in watch-later views and their previews, x removes the selected video and X (Shift-X) prompts to remove the current filtered/category subset. Configure view.collection.remove.selected and view.collection.remove.bulk. In watch-later views f cycles All/Unwatched/Watched; y confirms bulk, n/Esc cancels, and Enter never confirms. The other collection and watched status are unchanged. Watch-later titles are magenta; a checkmark marks watched videos in every table. Eligible acknowledged progress reaching 95% of a known duration marks watched; seeking near the end counts, launcher exit does not. Separate watch-later.toml and watch-state.toml sit beside config.toml. Private playback-sessions survive Tubular exit for next-launch recovery.\nSAVED VIDEOS: v saves the selected video once (also in previews); V opens a searchable saved-video collection selector; Alt-v opens the current explicit category's saved-video list, including from a category watch-later list. Both work in previews. Alt-w/Alt-v require a category view or category-scoped collection; Search, Feed, channel views and all-categories collections do not infer a category. Lists derive from current channel memberships and use offline metadata in saved-videos.toml beside config.toml. Only in saved-video views and their previews, x removes the selected video and X prompts to remove the current category/list subset, without changing watch later or watched state. Search, Feed, category and channel views and their previews never remove collection membership. Saved titles are green, taking precedence over watch-later magenta. Saved lists have no watched filter; bulk removal snapshots the current list. Both collections share the temporary tab and cached back/forward history.\nCOLLECTION SELECTORS (W / V): All categories (combined) is the first entry, followed by configured categories. Start in NORMAL; j/k or arrows move; i enters INSERT to filter by a case-insensitive substring. Arrows or Ctrl-J/K move while typing; Enter opens the selected scope, or does nothing with no matches. Esc returns to NORMAL then closes without navigating; reopening clears the filter. Collection selectors do not change category pins.\nSearch and Feed are permanent tabs. Pinned categories also stay visible; one temporary category tab is replaced by each new unpinned selection.\nTab/Shift-Tab cycle only visible tabs; Esc or - in NORMAL goes back through view history; + goes forward. Both restore cached rows, selection, and pagination, including replaced temporary tabs. New view navigation clears the forward branch. Removed views are skipped and empty history does nothing. Esc first closes popups or leaves INSERT without navigating; minus and plus type normally in input fields. r reloads config and refreshes the current view from its first page.\nCATEGORY BROWSER (c): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter browses; p immediately saves pin/unpin; R renames the hovered category; Esc closes. Choosing a pinned category focuses its existing tab and clears the temporary tab.\nCATEGORY FILTER INSERT: type a case-insensitive substring to filter live; arrows or Ctrl-J/K move; Enter browses the selected match; Esc returns to NORMAL with the filter retained. In NORMAL, j/k and pinning operate on the filtered list. No matches makes Enter/p a no-op. Reopening the selector clears the filter.\nCHANNEL BROWSER (u): starts NORMAL; j/k or arrows move; i enters INSERT to filter; Enter opens only the selected channel's videos; Esc closes. INSERT uses case-insensitive filtering, arrows or Ctrl-J/K to move, Enter to browse, and Esc to return to NORMAL. Empty matches do nothing.\nSaved choices include registered channels (even untagged) and category-only subscriptions, deduplicated by resolved identity; mapped handles label aliases. Channel selection never changes subscriptions or pins.\nChannels and unpinned categories share one temporary browsing tab. Choosing either replaces the previous temporary view while pinned category tabs remain visible. Revisiting or navigating back restores cached rows, selection, and page position. Channel views use the same infinite scrolling but extract only their chosen channel; S remains category-only.\nVIDEO CHANNEL (U / Shift-U): opens the selected video's creator in the same temporary channel tab, reusing cached rows and pagination without subscribing. A canonical channel ID or valid @handle is required; unavailable metadata leaves the current view unchanged. Saved mappings resolve handle-only rows. Unsaved channels do not appear in the saved selector unless you save them.\nReloading config relabels stable saved identities and removes explicitly unsubscribed channels; removing the active saved channel falls back to Search. Unsaved channels opened with U remain browseable for the session.\nHidden category and channel views retain loaded rows, selection, and pagination for this session. Temporary tabs do not survive restarting; pins do.\nPUBLISHED: Every video view shows YYYY-MM-DD. Exact upload dates take precedence; ~ marks an estimated UTC date from flat metadata, and Unknown means unavailable. Dates are cached without fetching full metadata for every video.\nLOADING: Result/feed and preview titles show an animated spinner while content loads, including further pages and thumbnail decoding/resizing; it disappears when ready or on error.\nFeed sits immediately right of Search and combines every saved [channels] registration, including untagged channels, plus channels entered directly in tags.\nChannel URLs and videos are deduplicated; Feed keeps its cached selection and page position when only tag assignments change.\nSubscriptions: $XDG_CONFIG_HOME/tubular/config.toml, or ~/.config/tubular/config.toml when XDG_CONFIG_HOME is unset/empty/relative.\nExample TOML:\n[settings]\npinned_categories = [\"Favorites\"]\n[categories]\nCreators = [\"@jawed\"]\nFavorites = [\"@jawed\"]\n[channels]\n\"@jawed\" = \"https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A\"\n[settings].pinned_categories is an array of category names; pins referencing removed categories are ignored. Settings, channels, categories and keys tables can appear in any order. Add [settings] above pinned_categories in older configs; root-level pins are no longer accepted.\n[categories] defines flat, overlapping tags, each with its own feed; there is no hierarchy or inheritance.\nEntries accept @handles, HTTPS YouTube channel URLs, or canonical UC channel IDs.\n[channels] registers saved handles with canonical channel URLs/IDs; feeds use these stable identities.\nChecked tags save the handle plus its mapping; existing URLs/aliases are replaced with the handle, preserving comments.\nUnchecked tags remove only that channel's tag assignment; other creators, omitted tags, empty tag arrays, saved registrations, and pins are retained.\nUntagging does not unsubscribe from Feed. To unsubscribe, remove all of that channel's [channels] mappings and tag entries, then press r.\nUnmapped handles work directly. Existing URL entries still load.\nSaving a single channel requires a handle and canonical ID; unavailable metadata or conflicting mappings are reported without changing the file.\nPAGINATION: Search loads 20 results per page; Feed and tags request 20 uploads per channel's /videos tab per page.\nMoving near the loaded end in NORMAL fetches more asynchronously, preserving existing rows, selection, and previews. Insert mode and open popups pause automatic prefetch.\nFeed views merge newest-first using approximate upload dates. Older uploads wait while newer channel pages catch up; undated uploads appear when the feed is exhausted.\nStatus shows the page and whether more is available or the end is reached. Switching views cancels pending extraction; returning resumes the same range with cached rows intact.\nPage errors retain loaded rows and stop automatic retries; r reloads config and restarts the current view from page one.\nDeep pages may take longer because yt-dlp offsets re-traverse earlier continuation pages.\nTAGS (s): existing assignments are checked; j/k or arrows move; Space toggles; Enter saves all staged memberships; n prompts for a new tag; R renames the selected tag; Esc cancels staged memberships without undoing committed renames.\nTAG FILTER (s/S): i edits a case-insensitive substring filter; arrows or Ctrl-J/K move through matches while typing. Enter or Esc returns to NORMAL without saving or clearing the filter. Hidden staged memberships are retained; no matches makes navigation/toggling a no-op. Reopening clears the filter.\nBULK TAGS (S, category views only): edits all saved channels in the current category, not just channels with loaded videos. Tags shared by every target start checked; mixed memberships start unchecked. Enter atomically applies the exact checked tag set to every target, removing targets from all unchecked categories while leaving other channels untouched. Empty categories, registrations, pins, and comments remain. n stages a new category for all targets; Esc cancels without saving. If source channels changed while editing, close and reopen. Clearing all tags is refused if any target lacks a [channels] registration, so category-only subscriptions are not accidentally lost.\nCATEGORY RENAME (R / Shift-R): rename the current category or category-scoped collection, the hovered category in category/collection selectors, or a selected tag in single/bulk tagging. Combined and channel rows cannot be renamed. The prefilled floating input uses shared editing controls; Enter commits, Esc cancels. Existing category identity is saved immediately, with memberships, pins, comments, cached selection/pages/watch filters and navigation history preserved. Tag membership changes remain staged until the main tag-list Enter; an unstored new tag rename also stays staged. Invalid, duplicate, or externally changed source categories keep the prompt open without writes; close dialogs and reload config with r before retrying external changes. Configure category.rename; R remains literal in filter/search inputs.\nNEW TAG: type a name; Enter stages it checked, clears the filter, selects the new tag, and returns to the list; Esc abandons the name; Ctrl-U clears.\nNew tags and membership changes reach disk only on Enter in the main tag list.\nVIDEO INFO: d opens thumbnail and details; d again expands the full description inside the same popup; another d returns to info. Esc closes.\nThe popup uses up to 70% width and 60% height, capped at 96 by 20 cells.\nFull descriptions have one-column left/right padding and an empty line above the bold title and dimmed author/publication row. Title, metadata, and description scroll together as one document; paragraphs are preserved. Up/Down or Ctrl-J/K scroll lines and PgUp/PgDn scroll pages. Plain j/k selects videos in either mode.\nThumbnails have one column of left padding and two columns before the text. Image space is reserved before loading so metadata does not shift when decoding finishes.\nThe title is bold, only author/publication metadata is dimmed, and readable excerpts end with an ellipsis when clipped.\nImages fit proportionally on resize. Terminal graphics are detected at startup, falling back to colored half-blocks.\nDownloading, JPEG decoding, and image resizing/encoding do not block navigation.\nSwitching info/description retains in-flight work and cached content. Full descriptions have a session LRU of 64; thumbnails retain eight inactive images plus the selected image.\ni, c, s, S, view changes, and refresh dismiss the preview; successful refresh clears its caches. Typing d or t in INSERT edits text.\nCtrl-C quits in any mode or popup.\nThe player CLI exits once macOS accepts the handoff; Tubular reports launcher errors, while later extraction/playback errors appear in Terebi.\nSelecting another video opens a new Terebi player, leaving existing playback untouched. Quitting Tubular cancels only pending metadata/CLI work; Terebi continues playing independently.\nExample: tubular \"rust programming\"" )] struct Options { /// Terebi CLI executable; must support --new-instance, --url, and --progress-file. @@ -191,6 +191,27 @@ impl ViewSource { Self::Channel(identity) => std::slice::from_ref(identity), } } + + fn category(&self) -> Option<&str> { + match self { + Self::Category(name) | Self::WatchLater(Some(name)) | Self::SavedVideos(Some(name)) => { + Some(name) + } + _ => None, + } + } + + fn rename_category(&mut self, old: &str, new: &str) -> bool { + match self { + Self::Category(name) | Self::WatchLater(Some(name)) | Self::SavedVideos(Some(name)) + if name.as_str() == old => + { + *name = new.to_owned(); + true + } + _ => false, + } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -390,6 +411,13 @@ impl CategoryDialog { } } +struct CategoryRename { + old: String, + name: String, + persisted: bool, + error: Option, +} + fn filter_choices( query: &str, search_names: &[String], @@ -454,7 +482,7 @@ enum PreviewMode { } struct KeyHints { - footers: [(Context, String); 13], + footers: [(Context, String); 14], preview: [String; 4], browser: [String; 4], tags: [String; 3], @@ -616,6 +644,16 @@ impl KeyHints { key(Action::Interrupt) ), ), + ( + Context::RenameInput, + format!( + "RENAME · {} commit · {} cancel · {} clear · {} quit", + key(Action::InputSubmit), + key(Action::InputCancel), + key(Action::DeleteLine), + key(Action::Interrupt) + ), + ), ( Context::Help, format!( @@ -669,10 +707,11 @@ impl KeyHints { ], browser: [ format!( - " Categories · {} filter · {} browse · {} pin ", + " Categories · {} filter · {} browse · {} pin · {} rename ", key(Action::SelectorEdit), key(Action::SelectorOpen), - key(Action::SelectorPin) + key(Action::SelectorPin), + key(Action::CategoryRename) ), format!( " Channels · {} filter · {} browse ", @@ -680,25 +719,28 @@ impl KeyHints { key(Action::SelectorOpen) ), format!( - " Watch later · {} filter · {} browse ", + " Watch later · {} filter · {} browse · {} rename ", key(Action::SelectorEdit), - key(Action::SelectorOpen) + key(Action::SelectorOpen), + key(Action::CategoryRename) ), format!( - " Saved videos · {} filter · {} browse ", + " Saved videos · {} filter · {} browse · {} rename ", key(Action::SelectorEdit), - key(Action::SelectorOpen) + key(Action::SelectorOpen), + key(Action::CategoryRename) ), ], tags: [ format!( - "{}/{} move · {} filter · {} toggle · {} save · {} new · {} cancel", + "{}/{} move · {} filter · {} toggle · {} save · {} new · {} rename · {} cancel", key(Action::TagNext), key(Action::TagPrevious), key(Action::TagEdit), key(Action::TagToggle), key(Action::TagSave), key(Action::TagNew), + key(Action::CategoryRename), key(Action::TagCancel) ), format!( @@ -776,6 +818,7 @@ struct App { view_history: Vec, forward_history: Vec, category_dialog: Option, + category_rename: Option, browser: Option, temporary_view: Option, preview: Option, @@ -847,6 +890,7 @@ impl App { view_history: Vec::new(), forward_history: Vec::new(), category_dialog: None, + category_rename: None, browser: None, temporary_view: None, preview: None, @@ -1369,6 +1413,7 @@ impl App { self.job.is_none() && self.mode == Mode::Normal && self.category_dialog.is_none() + && self.category_rename.is_none() && self.browser.is_none() && self.collection_removal.is_none() && self.preview.is_none() @@ -1674,6 +1719,208 @@ impl App { } } + fn open_category_rename(&mut self) { + let name = if let Some(dialog) = &self.category_dialog { + dialog + .selection + .selected() + .and_then(|row| dialog.matches.get(row)) + .and_then(|index| dialog.tags.keys().nth(*index)) + .cloned() + } else if let Some(browser) = &self.browser { + browser + .selection + .selected() + .and_then(|row| browser.matches.get(row)) + .and_then(|index| browser.choices.get(*index)) + .and_then(|(_, source)| source.category()) + .map(str::to_owned) + } else { + self.views[self.view_index] + .source + .category() + .map(str::to_owned) + }; + if let Some(name) = name { + self.close_preview(); + self.category_rename = Some(CategoryRename { + persisted: self.config.categories.contains_key(&name), + old: name.clone(), + name, + error: None, + }); + } else { + self.status = "Select a category to rename.".into(); + } + } + + fn rename_key(&mut self, key: KeyEvent, action: Option) { + let mut rename = self.category_rename.take().expect("rename is open"); + match action { + Some(Action::InputCancel) => return, + Some(Action::InputSubmit) => match self.commit_category_rename(&rename) { + Ok(()) => return, + Err(error) => rename.error = Some(error.to_string()), + }, + _ => edit_input(&mut rename.name, key, action), + } + self.category_rename = Some(rename); + } + + fn commit_category_rename(&mut self, rename: &CategoryRename) -> io::Result<()> { + let new = rename.name.trim(); + let invalid = |message: &str| io::Error::new(io::ErrorKind::InvalidInput, message); + if new.is_empty() || rename.name.chars().any(char::is_control) { + return Err(invalid( + "Enter a nonempty category name without control characters.", + )); + } + if new != rename.old.as_str() + && self + .category_dialog + .as_ref() + .is_some_and(|dialog| dialog.tags.contains_key(new)) + { + return Err(invalid("That tag already exists.")); + } + let disk = config::load(&self.config_path)?; + if rename.persisted { + if disk + .categories + .get(&rename.old) + .is_none_or(|channels| Some(channels) != self.config.categories.get(&rename.old)) + { + return Err(invalid( + "This category changed or disappeared on disk. Close dialogs and reload config before renaming.", + )); + } + let config = config::rename_category(&self.config_path, &rename.old, new)?; + self.migrate_category_name(&rename.old, new); + self.apply_config(config); + } else { + if disk.categories.contains_key(&rename.old) || disk.categories.contains_key(new) { + return Err(invalid( + "That tag now exists on disk. Close dialogs and reload config before renaming.", + )); + } + self.migrate_staged_category_name(&rename.old, new); + } + if let Some(browser) = self.browser.take() { + self.open_browser(browser.kind); + let browser_new = self.browser.as_mut().expect("browser reopened"); + browser_new.query = browser.query; + if !new + .to_lowercase() + .contains(&browser_new.query.to_lowercase()) + { + browser_new.query.clear(); + } + browser_new.refilter(); + browser_new.selection.select( + browser_new + .choices + .iter() + .position(|(_, source)| source.category() == Some(new)) + .and_then(|index| browser_new.matches.binary_search(&index).ok()), + ); + } + self.status = if rename.persisted { + if self.category_dialog.is_some() { + format!( + "Renamed {} to {new}. Tag membership changes remain staged.", + rename.old + ) + } else { + format!("Renamed {} to {new}.", rename.old) + } + } else { + format!( + "Renamed staged tag {} to {new}; save tags to persist it.", + rename.old + ) + }; + Ok(()) + } + + fn migrate_staged_category_name(&mut self, old: &str, new: &str) { + let Some(dialog) = &mut self.category_dialog else { + return; + }; + if let Some(checked) = dialog.tags.remove(old) { + dialog.tags.insert(new.to_owned(), checked); + } + if let CategoryTarget::Category { name, channels } = &mut dialog.target + && name.as_str() == old + { + *name = new.to_owned(); + dialog.label = format!(" {new} · {} channels ", channels.len()); + } + dialog.search_names = dialog.tags.keys().map(|tag| tag.to_lowercase()).collect(); + if !new.to_lowercase().contains(&dialog.query.to_lowercase()) { + dialog.query.clear(); + } + dialog.refilter(); + dialog.selection.select( + dialog + .tags + .keys() + .position(|tag| tag == new) + .and_then(|index| dialog.matches.binary_search(&index).ok()), + ); + } + + fn migrate_category_name(&mut self, old: &str, new: &str) { + if old == new { + return; + } + // Migrate the comparison baseline too: apply_config must not treat a rename as + // deleting one cache and creating another, or cancel an unchanged feed job. + if let Some(channels) = self.config.categories.remove(old) { + self.config.categories.insert(new.to_owned(), channels); + } + for name in &mut self.config.pinned_categories { + if name.as_str() == old { + *name = new.to_owned(); + } + } + for view in &mut self.views { + if view.source.rename_category(old, new) { + view.name = match &view.source { + ViewSource::WatchLater(_) => format!("{new} · Watch later"), + ViewSource::SavedVideos(_) => format!("{new} · Saved videos"), + _ => new.to_owned(), + }; + view.title = format!(" {} ", view.name); + } + } + for source in self + .view_history + .iter_mut() + .chain(&mut self.forward_history) + .chain(self.temporary_view.iter_mut()) + { + source.rename_category(old, new); + } + let active = self.views[self.view_index].source.clone(); + let category_end = self + .views + .iter() + .position(|view| { + matches!( + view.source, + ViewSource::Channel(_) | ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + ) + }) + .unwrap_or(self.views.len()); + self.views[2..category_end].sort_unstable_by(|a, b| a.name.cmp(&b.name)); + self.view_index = self + .views + .iter() + .position(|view| view.source == active) + .expect("active view retained"); + self.migrate_staged_category_name(old, new); + } + fn open_category_picker(&mut self) { self.close_preview(); let view = &self.views[self.view_index]; @@ -2033,6 +2280,8 @@ impl App { fn key_context(&self) -> Context { if self.help_popup.is_some() { Context::Help + } else if self.category_rename.is_some() { + Context::RenameInput } else if let Some(dialog) = &self.category_dialog { if dialog.name.is_some() { Context::TagInput @@ -2067,6 +2316,16 @@ impl App { } match self.key_context() { Context::SearchInput => paste_input(&mut self.query, text), + Context::RenameInput => { + paste_input( + &mut self + .category_rename + .as_mut() + .expect("rename is active") + .name, + text, + ); + } Context::SelectorInput => { let browser = self.browser.as_mut().expect("selector input is active"); paste_input(&mut browser.query, text); @@ -2146,6 +2405,14 @@ impl App { }); return false; } + if self.category_rename.is_some() { + self.rename_key(key, action); + return false; + } + if action == Some(Action::CategoryRename) { + self.open_category_rename(); + return false; + } if self.category_dialog.is_some() { self.category_key(key, action); return false; @@ -2562,6 +2829,7 @@ impl App { } self.draw_category_dialog(frame); self.draw_browser(frame); + self.draw_category_rename(frame); self.draw_help(frame); self.draw_collection_removal(frame); } @@ -2717,6 +2985,69 @@ impl App { } } + fn draw_category_rename(&self, frame: &mut Frame) { + let Some(rename) = &self.category_rename else { + return; + }; + let area = frame.area(); + let width = area.width.saturating_sub(4).min(64); + let height = area.height.saturating_sub(4).min(10); + let popup = Rect::new( + area.x + (area.width - width) / 2, + area.y + (area.height - height) / 2, + width, + height, + ); + frame.render_widget(Clear, popup); + let block = Block::bordered() + .title(" Rename category ") + .title_bottom(Line::from(vec![ + Span::raw(" "), + Span::raw(rename.old.as_str()), + Span::raw(" "), + ])) + .border_style(Style::new().fg(Color::Cyan)); + let [body, warning, error, help] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(2), + Constraint::Length(if rename.error.is_some() { 2 } else { 0 }), + Constraint::Length(1), + ]) + .areas(block.inner(popup)); + frame.render_widget(block, popup); + let text = Line::raw(rename.name.as_str()); + let scroll = text + .width() + .saturating_sub(body.width.saturating_sub(1) as usize); + let cursor = text.width().saturating_sub(scroll) as u16; + frame.render_widget( + Paragraph::new(text).scroll((0, scroll.min(u16::MAX as usize) as u16)), + body, + ); + if body.width != 0 && body.height != 0 { + frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y)); + } + frame.render_widget(Paragraph::new(if !rename.persisted { + "This new tag stays staged until Save tags." + } else if self.category_dialog.is_some() { + "Submitting saves this rename immediately. Tag membership changes stay staged until Save tags." + } else { + "Submitting saves this rename immediately; existing memberships and pins are preserved." + }).wrap(Wrap { trim: true }).dim(), warning); + if let Some(message) = &rename.error { + frame.render_widget( + Paragraph::new(message.as_str()) + .wrap(Wrap { trim: true }) + .fg(Color::Red), + error, + ); + } + frame.render_widget( + Paragraph::new(self.key_hints.footer(Context::RenameInput)).dim(), + help, + ); + } + fn draw_category_dialog(&mut self, frame: &mut Frame) { let Some(dialog) = &mut self.category_dialog else { return; @@ -3003,6 +3334,479 @@ mod tests { app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); } + fn rename_app(label: &str, keys: &str) -> App { + let root = + std::env::temp_dir().join(format!("tubular-rename-{label}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("config.toml"); + std::fs::write(&path, format!( + "[settings]\npinned_categories = ['Alpha']\n[channels]\n'@Alpha' = 'UCaYhcUwRBNscFNUKTjgPFiA'\n[categories]\nAlpha = ['@Alpha']\nOther = []\n{keys}" + )).unwrap(); + App::new( + Options::parse_from(["tubular"]), + path.clone(), + config::load(&path).unwrap(), + ) + } + + fn rename_press(app: &mut App, code: KeyCode) { + app.key(KeyEvent::new(code, KeyModifiers::NONE)); + } + + fn rename_to(app: &mut App, name: &str) { + app.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::SHIFT)); + assert_eq!( + app.category_rename.as_ref().unwrap().name, + app.category_rename.as_ref().unwrap().old + ); + app.key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.paste(name); + rename_press(app, KeyCode::Enter); + assert!(app.category_rename.is_none()); + } + + #[test] + fn category_rename_preserves_pins_cached_pages_collection_scopes_and_history() { + let mut app = rename_app("cache", ""); + let videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"l-GaaivVJCE","title":"Second"}]}"# + ).unwrap().videos; + for source in [ + ViewSource::WatchLater(Some("Alpha".into())), + ViewSource::SavedVideos(Some("Alpha".into())), + ] { + let mut view = View::new("Alpha collection".into(), source); + view.videos = videos.clone(); + view.selection.select(Some(1)); + view.loaded = true; + view.watch_filter = WatchFilter::Watched; + app.views.push(view); + } + app.view_index = 2; + let view = &mut app.views[2]; + view.videos = videos.clone(); + view.pending = vec![videos[0].clone()]; + view.selection.select(Some(1)); + view.loaded = true; + view.next_start = 41; + view.has_more = true; + view.page_error = Some("keep page failure".into()); + app.temporary_view = Some(ViewSource::SavedVideos(Some("Alpha".into()))); + app.view_history = vec![ViewSource::WatchLater(Some("Alpha".into()))]; + app.forward_history = vec![ViewSource::SavedVideos(Some("Alpha".into()))]; + rename_to(&mut app, "Zeta"); + let view = &app.views[app.view_index]; + assert_eq!(view.source, ViewSource::Category("Zeta".into())); + assert_eq!(view.name, "Zeta"); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.videos.len(), 2); + assert_eq!(view.pending.len(), 1); + assert_eq!(view.next_start, 41); + assert!(view.loaded && view.has_more); + assert_eq!(view.page_error.as_deref(), Some("keep page failure")); + assert_eq!(app.config.pinned_categories, ["Zeta"]); + assert_eq!(app.views[2].source, ViewSource::Category("Other".into())); + assert_eq!( + app.temporary_view, + Some(ViewSource::SavedVideos(Some("Zeta".into()))) + ); + assert_eq!( + app.view_history, + [ViewSource::WatchLater(Some("Zeta".into()))] + ); + assert_eq!( + app.forward_history, + [ViewSource::SavedVideos(Some("Zeta".into()))] + ); + for view in &app.views { + if matches!( + view.source, + ViewSource::WatchLater(_) | ViewSource::SavedVideos(_) + ) { + assert_eq!(view.source.category(), Some("Zeta")); + assert_eq!(view.selection.selected(), Some(1)); + assert_eq!(view.watch_filter, WatchFilter::Watched); + assert_eq!(view.videos.len(), 2); + } + } + app.back_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::WatchLater(Some("Zeta".into())) + ); + app.forward_view(); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Zeta".into()) + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn rename_selectors_keep_renamed_match_selected_and_ignore_noncategory_rows() { + for (label, kind) in [ + ("categories", BrowserKind::Categories), + ("later", BrowserKind::WatchLater), + ("saved", BrowserKind::SavedVideos), + ("channels", BrowserKind::Channels), + ] { + let mut app = rename_app(label, ""); + app.open_browser(kind); + if kind != BrowserKind::Categories { + rename_press(&mut app, KeyCode::Char('R')); + assert!(app.category_rename.is_none()); + } + if kind == BrowserKind::Channels { + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + continue; + } + let browser = app.browser.as_mut().unwrap(); + browser.query = "alp".into(); + browser.refilter(); + rename_press(&mut app, KeyCode::Char('R')); + rename_press(&mut app, KeyCode::Esc); + assert_eq!(app.browser.as_ref().unwrap().query, "alp"); + rename_to(&mut app, "Zeta"); + let browser = app.browser.as_ref().unwrap(); + assert!(browser.query.is_empty()); + let selected = browser.matches[browser.selection.selected().unwrap()]; + assert_eq!(browser.choices[selected].1.category(), Some("Zeta")); + assert!(app.config.pinned_categories.contains(&"Zeta".into())); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + for (label, source) in [ + ( + "later-current", + ViewSource::WatchLater(Some("Alpha".into())), + ), + ( + "saved-current", + ViewSource::SavedVideos(Some("Alpha".into())), + ), + ] { + let mut app = rename_app(label, ""); + let mut view = View::new("Alpha collection".into(), source); + view.loaded = true; + app.views.push(view); + app.view_index = app.views.len() - 1; + rename_to(&mut app, "Zeta"); + assert_eq!(app.views[app.view_index].source.category(), Some("Zeta")); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + } + + #[test] + fn rename_validation_keeps_prompt_and_disk_unchanged_including_external_edits() { + let mut app = rename_app("invalid", ""); + app.view_index = 2; + let original = std::fs::read_to_string(&app.config_path).unwrap(); + for name in ["", " ", "Other", "bad\nname"] { + rename_press(&mut app, KeyCode::Char('R')); + app.category_rename.as_mut().unwrap().name = name.into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.as_ref().unwrap().error.is_some()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Alpha".into()) + ); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + rename_press(&mut app, KeyCode::Esc); + } + rename_to(&mut app, "Alpha"); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + for external in [ + original.replace("Alpha = ['@Alpha']", "Alpha = []"), + original.replace("Alpha = ['@Alpha']", "Removed = ['@Alpha']"), + original.replace("Other = []", "Other = []\nZeta = []"), + "not valid toml = [".into(), + ] { + std::fs::write(&app.config_path, &external).unwrap(); + rename_press(&mut app, KeyCode::Char('R')); + app.category_rename.as_mut().unwrap().name = "Zeta".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.as_ref().unwrap().error.is_some()); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), external); + assert!(app.config.categories.contains_key("Alpha")); + rename_press(&mut app, KeyCode::Esc); + } + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn tag_rename_preserves_staging_retargets_bulk_source_and_saves_only_on_tag_submit() { + for bulk in [false, true] { + let mut app = rename_app(if bulk { "bulk" } else { "single" }, ""); + app.view_index = 2; + let channel = app.config.categories["Alpha"][0].clone(); + if bulk { + app.open_bulk_category_picker(); + } else { + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: channel.clone(), + }, + " @Alpha ".into(), + [("Alpha".into(), true), ("Other".into(), false)].into(), + )); + } + let dialog = app.category_dialog.as_mut().unwrap(); + dialog.tags.insert("Staged".into(), true); + dialog.tags.insert("Unchecked".into(), false); + dialog.tags.insert("Other".into(), true); + dialog.search_names = dialog.tags.keys().map(|tag| tag.to_lowercase()).collect(); + dialog.query = "Alpha".into(); + dialog.refilter(); + rename_to(&mut app, "Zeta"); + let disk = config::load(&app.config_path).unwrap(); + assert_eq!(disk.categories["Zeta"], std::slice::from_ref(&channel)); + assert!(disk.categories["Other"].is_empty()); + assert!(!disk.categories.contains_key("Staged")); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(dialog.tags["Zeta"] && dialog.tags["Other"] && dialog.tags["Staged"]); + assert!(!dialog.tags["Unchecked"]); + if let CategoryTarget::Category { name, channels } = &dialog.target { + assert_eq!(name, "Zeta"); + assert_eq!(channels, std::slice::from_ref(&channel)); + } + rename_press(&mut app, KeyCode::Esc); + assert!(app.category_dialog.is_none()); + assert!(config::load(&app.config_path).unwrap().categories["Other"].is_empty()); + // Reopen with staged memberships and rename the bulk source a second time; + // saving must use the new identity, not the now-missing original key. + app.category_dialog = Some(CategoryDialog::new( + if bulk { + CategoryTarget::Category { + name: "Zeta".into(), + channels: vec![channel.clone()], + } + } else { + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: channel.clone(), + } + }, + " target ".into(), + [ + ("Zeta".into(), true), + ("Other".into(), true), + ("Staged".into(), true), + ] + .into(), + )); + let dialog = app.category_dialog.as_mut().unwrap(); + dialog.query = "Zeta".into(); + dialog.refilter(); + rename_to(&mut app, "Beta"); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_dialog.is_none()); + let disk = config::load(&app.config_path).unwrap(); + for tag in ["Beta", "Other", "Staged"] { + assert_eq!(disk.categories[tag], std::slice::from_ref(&channel)); + } + assert!(!disk.categories.contains_key("Zeta")); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + } + + #[test] + fn new_tag_rename_is_local_and_rejects_staged_and_external_collisions() { + let mut app = rename_app("new-tag", ""); + let channel = app.config.categories["Alpha"][0].clone(); + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel, + }, + " @Alpha ".into(), + [ + ("Alpha".into(), true), + ("Other".into(), false), + ("Staged".into(), false), + ] + .into(), + )); + let dialog = app.category_dialog.as_mut().unwrap(); + dialog.query = "Staged".into(); + dialog.refilter(); + let original = std::fs::read_to_string(&app.config_path).unwrap(); + rename_press(&mut app, KeyCode::Char('R')); + app.category_rename.as_mut().unwrap().name = "Other".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.as_ref().unwrap().error.is_some()); + app.category_rename.as_mut().unwrap().name = "Alpha".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.as_ref().unwrap().error.is_some()); + std::fs::write(&app.config_path, format!("{original}OnDisk = []\n")).unwrap(); + app.category_rename.as_mut().unwrap().name = "OnDisk".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.as_ref().unwrap().error.is_some()); + std::fs::write(&app.config_path, format!("{original}Staged = []\n")).unwrap(); + app.category_rename.as_mut().unwrap().name = "Renamed new".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.as_ref().unwrap().error.is_some()); + std::fs::write(&app.config_path, &original).unwrap(); + app.category_rename.as_mut().unwrap().name = "Renamed new".into(); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.is_none()); + assert_eq!(std::fs::read_to_string(&app.config_path).unwrap(), original); + let dialog = app.category_dialog.as_ref().unwrap(); + assert!(!dialog.tags["Renamed new"]); + assert!(!dialog.tags.contains_key("Staged")); + rename_press(&mut app, KeyCode::Char(' ')); + rename_press(&mut app, KeyCode::Enter); + assert!( + config::load(&app.config_path) + .unwrap() + .categories + .contains_key("Renamed new") + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn unpinned_category_rename_retains_its_temporary_tab_and_cached_rows() { + let mut app = rename_app("unpinned", ""); + let config = config::set_category_pinned(&app.config_path, "Alpha", false).unwrap(); + app.apply_config(config); + app.view_index = 2; + app.views[2].loaded = true; + app.views[2].next_start = 61; + app.temporary_view = Some(ViewSource::Category("Alpha".into())); + rename_to(&mut app, "Zeta"); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Zeta".into()) + ); + assert_eq!(app.views[app.view_index].next_start, 61); + assert_eq!( + app.temporary_view, + Some(ViewSource::Category("Zeta".into())) + ); + assert!(app.config.pinned_categories.is_empty()); + assert_eq!(app.visible_views().count(), 3); + assert_eq!( + app.visible_views().last().unwrap().1.source, + ViewSource::Category("Zeta".into()) + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn rename_uses_configured_actions_and_uppercase_r_stays_literal_in_all_inputs() { + let keys = "[keys]\ncategory.rename = 'f6'\ninput.submit = 'f7'\ninput.cancel = 'f8'\ninput.delete.line = 'f9'\ninput.delete.word = 'f10'\ninput.delete.character = 'f11'\n"; + for entry in ["view", "browser", "tags"] { + let mut app = rename_app(&format!("remapped-{entry}"), keys); + app.view_index = 2; + match entry { + "browser" => app.open_browser(BrowserKind::Categories), + "tags" => { + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: app.config.categories["Alpha"][0].clone(), + }, + " target ".into(), + [("Alpha".into(), true), ("Other".into(), false)].into(), + )) + } + _ => {} + } + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert!(app.category_rename.is_none(), "default action is replaced"); + rename_press(&mut app, KeyCode::F(6)); + assert_eq!(app.key_context(), Context::RenameInput); + rename_press(&mut app, KeyCode::F(9)); + app.paste("日本語 words"); + rename_press(&mut app, KeyCode::F(10)); + assert_eq!(app.category_rename.as_ref().unwrap().name, "日本語 "); + rename_press(&mut app, KeyCode::F(11)); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + app.paste("\n?\t"); + assert_eq!(app.category_rename.as_ref().unwrap().name, "日本語R ? "); + assert!(app.help_popup.is_none()); + rename_press(&mut app, KeyCode::F(8)); + assert!(app.category_rename.is_none()); + assert!(app.config.categories.contains_key("Alpha")); + rename_press(&mut app, KeyCode::F(6)); + rename_press(&mut app, KeyCode::F(9)); + app.paste("Zeta"); + rename_press(&mut app, KeyCode::Enter); + assert!(app.category_rename.is_some(), "default submit is replaced"); + rename_press(&mut app, KeyCode::F(7)); + assert!(app.category_rename.is_none()); + assert!( + config::load(&app.config_path) + .unwrap() + .categories + .contains_key("Zeta") + ); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + let mut app = rename_app("literal-r", ""); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!(app.query, "R"); + assert!(app.category_rename.is_none()); + rename_press(&mut app, KeyCode::Esc); + app.open_browser(BrowserKind::Categories); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!(app.browser.as_ref().unwrap().query, "R"); + assert!(app.category_rename.is_none()); + app.browser = None; + app.category_dialog = Some(CategoryDialog::new( + CategoryTarget::Channel { + handle: "@Alpha".into(), + channel: app.config.categories["Alpha"][0].clone(), + }, + " target ".into(), + [("Alpha".into(), true)].into(), + )); + rename_press(&mut app, KeyCode::Char('i')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!(app.category_dialog.as_ref().unwrap().query, "R"); + assert!(app.category_rename.is_none()); + rename_press(&mut app, KeyCode::Esc); + rename_press(&mut app, KeyCode::Char('n')); + app.key(KeyEvent::new(KeyCode::Char('R'), KeyModifiers::SHIFT)); + assert_eq!( + app.category_dialog.as_ref().unwrap().name.as_deref(), + Some("R") + ); + assert!(app.category_rename.is_none()); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + + #[test] + fn rename_prompt_cursor_scrolls_by_display_width_without_changing_selection() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = rename_app("rename-cursor", ""); + app.view_index = 2; + rename_press(&mut app, KeyCode::Char('R')); + let mut terminal = Terminal::new(TestBackend::new(24, 16)).unwrap(); + for (text, x) in [ + ("", 3), + ("日本語", 9), + ("e\u{301}", 4), + ("abcdefghijklmnopqrstuvwxyz", 20), + ] { + app.category_rename.as_mut().unwrap().name = text.into(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + assert_eq!(terminal.get_cursor_position().unwrap(), (x, 4).into()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Category("Alpha".into()) + ); + } + rename_press(&mut app, KeyCode::Esc); + assert!(app.category_rename.is_none()); + std::fs::remove_dir_all(app.config_path.parent().unwrap()).unwrap(); + } + #[test] fn collection_removal_is_inert_in_ordinary_views_and_their_previews() { let mut app = app(&["tubular"]);