From aaa13ab905e845a7f64f5790e37e603ccb0e0df5 Mon Sep 17 00:00:00 2001 From: hl-valdemar Date: Sun, 11 Oct 2026 17:35:58 +0200 Subject: [PATCH] feat: configure keybindings and print default config --- README.md | 30 +- src/config.rs | 147 +++++++++ src/keys.rs | 816 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 762 +++++++++++++++++++++++++++++++--------------- 4 files changed, 1506 insertions(+), 249 deletions(-) create mode 100644 src/keys.rs diff --git a/README.md b/README.md index 3069ba7..6be5dfe 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,8 @@ Each selection opens an independent player instance without replacing existing p ## Controls +These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys. + ### Normal mode | Key | Action | @@ -142,7 +144,7 @@ Favorites = ["@jawed"] `[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available. -`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, and `[categories]` sections can appear in any order; pin/unpin preserves the existing section order and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted. +`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, `[categories]`, and `[keys]` sections can appear in any order; pin/unpin and tag edits preserve the existing section order, key overrides, and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted. Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly. @@ -153,6 +155,32 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC Bulk editing refuses to clear all tags when a target has no `[channels]` registration, preventing accidental unsubscription of category-only channels. If source memberships change while a bulk dialog is open, close and reopen it before saving. +## Configurable keybindings + +Print a complete default configuration, including every action and its default bindings: + +```sh +tubular --default-config +``` + +This prints TOML to stdout and exits without reading or writing config, starting the TUI, or needing `yt-dlp`, Terebi, or a terminal. Copy the settings you want into your config; `r` reloads them. + +```toml +[keys] +video.info.overview = "I" +video.info.description = "d" +video.info.hide = ["escape", "backspace"] +view.history.previous = ["-", "escape"] +view.history.next = "+" +video.play = ["enter", "ctrl+p"] +``` + +Each string or array **replaces** that action's default aliases; omitted actions keep their defaults, and `[]` disables an action. Dotted assignments, corresponding subtables such as `[keys.video.info]`, and quoted action names are supported. Unknown actions, invalid chords, duplicate action declarations, and ambiguous bindings in the same context are rejected before saving. + +Characters are case-sensitive (`i` differs from `I`). Named keys are case-insensitive: `escape`, `enter`, `tab`, `space`, arrows, `home`, `end`, `pageup`, `pagedown`, `backspace`, `delete`, and `f1`–`f24`. Combine modifiers with `+`, for example `ctrl+j`, `alt+backspace`, `cmd+backspace`, or `shift+tab`. `control`, `option`, and `super` are accepted aliases; `+` itself, or `ctrl++`, can be bound. The terminal must transmit the chord for it to work. + +Using the same binding for overview and description preserves the default cycle. Distinct bindings, as above, open either view directly. Preview close takes precedence over history back; description scrolling takes precedence over video selection for shared bindings. View commands do not run inside text inputs or selectors; those have their own actions listed by `--default-config`. + ## Loading and previews Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Individual channel views request only that channel's uploads, using the same pagination. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again. diff --git a/src/config.rs b/src/config.rs index 885793d..2d1a2ec 100644 --- a/src/config.rs +++ b/src/config.rs @@ -9,11 +9,14 @@ use std::{ use toml_edit::{Array, DocumentMut, Item, Table, value}; +use crate::keys; + #[derive(Default, Debug)] pub struct Config { pub channels: BTreeMap, pub categories: BTreeMap>, pub pinned_categories: Vec, + pub keys: keys::Bindings, } impl Config { @@ -70,10 +73,12 @@ pub fn load(path: &Path) -> io::Result { 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"))?; Ok(Config { channels, categories, pinned_categories, + keys, }) } @@ -96,6 +101,7 @@ pub fn set_channel_tags( // Validate the whole file before changing it or creating directories. parse_categories(&document, &mappings)?; let pinned_categories = parse_pins(&document)?; + let keys = keys::Bindings::from_toml(document.get("keys"))?; match mappings.get(&handle) { Some(existing) if existing != &channel => { return Err(invalid(format!( @@ -162,6 +168,7 @@ pub fn set_channel_tags( channels: mappings, categories, pinned_categories, + keys, }) } @@ -177,6 +184,7 @@ pub fn set_category_tags( let mappings = parse_channels(&document)?; let parsed_categories = parse_categories(&document, &mappings)?; let pinned_categories = parse_pins(&document)?; + let keys = keys::Bindings::from_toml(document.get("keys"))?; let targets = parsed_categories .get(source) .ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?; @@ -272,6 +280,7 @@ pub fn set_category_tags( channels: mappings, categories, pinned_categories, + keys, }) } @@ -341,6 +350,7 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result< let channels = parse_channels(&document)?; let categories = parse_categories(&document, &channels)?; let pins = parse_pins(&document)?; + let keys = keys::Bindings::from_toml(document.get("keys"))?; if !categories.contains_key(name) { return Err(invalid(format!("Unknown category: {name:?}"))); } @@ -370,6 +380,7 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result< channels, categories, pinned_categories, + keys, }) } @@ -652,6 +663,142 @@ mod tests { } } + #[test] + fn key_remaps_survive_all_saves_and_section_orders() { + use crate::keys::{Action, Context}; + use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + 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 = []\n\n[keys.selector]\npin = 'f2' # pin note\n"; + let sections = [ + ("keys", key_section), + ("settings", "[settings]\npinned_categories = []\n"), + ( + "channels", + "[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n", + ), + ("categories", "[categories]\nA = ['@example']\nB = []\n"), + ]; + for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] { + let original = order + .iter() + .map(|index| sections[*index].1) + .collect::(); + fs::write(&path, original).unwrap(); + let loaded = load(&path).unwrap(); + for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] { + assert_eq!( + loaded.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('x'), modifiers) + ), + Some(Action::Quit), + ); + } + assert_eq!( + loaded.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE) + ), + None, + ); + assert_eq!( + loaded.keys.resolve( + Context::SelectorNormal, + KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE) + ), + Some(Action::SelectorPin), + ); + assert_eq!( + loaded.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) + ), + None + ); + let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]); + let channel_saved = + set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap(); + let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]); + let category_saved = + set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap(); + let pin_saved = set_category_pinned(&path, "B", true).unwrap(); + for config in [ + channel_saved, + category_saved, + pin_saved, + load(&path).unwrap(), + ] { + assert_eq!( + config.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE) + ), + Some(Action::Quit) + ); + assert_eq!( + config.keys.resolve( + Context::Normal, + KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE) + ), + None + ); + assert_eq!( + config.keys.resolve( + Context::SelectorNormal, + KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE) + ), + Some(Action::SelectorPin) + ); + } + let saved = fs::read_to_string(&path).unwrap(); + assert!( + saved.contains(key_section), + "key formatting changed: {saved}" + ); + let document = saved.parse::().unwrap(); + assert_eq!( + document.iter().map(|(name, _)| name).collect::>(), + order + .iter() + .map(|index| sections[*index].0) + .collect::>(), + ); + } + } + + #[test] + fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() { + let directory = TestDirectory::new(); + let path = directory.0.join("config.toml"); + let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]); + let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()]; + for invalid_keys in [ + "[keys]\napp.quit = 42\n", + "[keys]\nunknown.action = 'x'\n", + "[keys]\napp.quit = 'not-a-key'\n", + "[keys]\nview.refresh = 'q'\n", + "keys = 'invalid'\n", + ] { + let original = format!( + "{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n" + ); + fs::write(&path, &original).unwrap(); + assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData); + let channel_error = + set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err(); + assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err(); + assert_eq!(category_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + let pin_error = set_category_pinned(&path, "B", true).unwrap_err(); + assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData); + assert_eq!(fs::read(&path).unwrap(), original.as_bytes()); + } + } + #[test] fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() { let directory = TestDirectory::new(); diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..31b7b47 --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,816 @@ +use std::{fmt::Write as _, io}; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use toml_edit::{Array, Item}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(usize)] +pub enum Context { + Normal, + Info, + Description, + SearchInput, + SelectorNormal, + SelectorInput, + TagList, + TagInput, + Help, +} + +const ALL: &[Context] = &[ + Context::Normal, + Context::Info, + Context::Description, + Context::SearchInput, + Context::SelectorNormal, + Context::SelectorInput, + Context::TagList, + Context::TagInput, + Context::Help, +]; +const VIEW: &[Context] = &[Context::Normal, Context::Info, Context::Description]; +const TEXT: &[Context] = &[ + Context::SearchInput, + Context::SelectorInput, + Context::TagInput, +]; +const NON_TEXT: &[Context] = &[ + Context::Normal, + Context::Info, + Context::Description, + Context::SelectorNormal, + Context::TagList, + Context::Help, +]; + +struct Definition { + action: Action, + name: &'static str, + description: &'static str, + contexts: &'static [Context], + defaults: &'static [&'static str], +} + +// The enum order, storage indices, documentation and default TOML all share this registry. +macro_rules! actions { + ($($action:ident, $name:literal, $description:literal, $contexts:expr, [$($key:literal),*];)*) => { + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + #[repr(usize)] + pub enum Action { $($action),* } + + const DEFINITIONS: &[Definition] = &[ + $(Definition { + action: Action::$action, + name: $name, + description: $description, + contexts: $contexts, + defaults: &[$($key),*], + }),* + ]; + }; +} + +actions! { + Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"]; + Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::Help], ["q"]; + Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"]; + InputSubmit, "input.submit", "Submit search, selector filter or new tag", 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"]; + DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"]; + SearchEdit, "search.edit", "Edit the video search", VIEW, ["i"]; + ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"]; + ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"]; + ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"]; + ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"]; + ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"]; + VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"]; + VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"]; + VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"]; + VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"]; + 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"]; + 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"]; + 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"]; + SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"]; + SelectorEdit, "selector.edit", "Edit the category or channel filter", &[Context::SelectorNormal], ["i"]; + SelectorOpen, "selector.open", "Open the selected category or channel", &[Context::SelectorNormal], ["enter"]; + SelectorPin, "selector.pin", "Toggle category pinning (no effect in channel picker)", &[Context::SelectorNormal], ["p"]; + SelectorClose, "selector.close", "Close the category or channel picker", &[Context::SelectorNormal], ["escape"]; + TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"]; + TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"]; + TagToggle, "tags.toggle", "Toggle the selected category tag", &[Context::TagList], ["space"]; + 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"]; + 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"]; + HelpPagePrevious, "help.page.previous", "Scroll help up one page", &[Context::Help], ["pageup"]; + HelpClose, "help.close", "Close keyboard help", &[Context::Help], ["escape"]; +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Key { + code: KeyCode, + modifiers: KeyModifiers, +} + +impl Key { + fn normalized(mut self) -> Self { + if self.code == KeyCode::BackTab { + self.code = KeyCode::Tab; + self.modifiers.insert(KeyModifiers::SHIFT); + } + if let KeyCode::Char(character) = self.code + && !character.is_control() + && self.modifiers.contains(KeyModifiers::SHIFT) + { + // Terminals usually report the already-shifted character. A single-character + // uppercase mapping also accepts synthetic shift+i as I without allocating. + let mut uppercase = character.to_uppercase(); + if let Some(first) = uppercase.next() + && uppercase.next().is_none() + { + self.code = KeyCode::Char(first); + } + self.modifiers.remove(KeyModifiers::SHIFT); + } + self + } + + fn parse(description: &str) -> Result { + let mut remaining = description; + let mut modifiers = KeyModifiers::NONE; + loop { + if remaining.chars().count() == 1 { + break; + } + let Some((modifier, key)) = remaining.split_once('+') else { + break; + }; + let flag = match modifier.to_ascii_lowercase().as_str() { + "ctrl" | "control" => KeyModifiers::CONTROL, + "alt" | "option" => KeyModifiers::ALT, + "super" | "cmd" | "command" => KeyModifiers::SUPER, + "shift" => KeyModifiers::SHIFT, + _ => { + return Err(format!( + "unknown modifier {modifier:?}; use ctrl, alt, super or shift" + )); + } + }; + if modifiers.contains(flag) { + return Err(format!("repeated modifier {modifier:?}")); + } + modifiers.insert(flag); + remaining = key; + } + let mut characters = remaining.chars(); + let code = match (characters.next(), characters.next()) { + (Some(character), None) if !character.is_control() => KeyCode::Char(character), + _ => match remaining.to_ascii_lowercase().as_str() { + "escape" | "esc" => KeyCode::Esc, + "enter" | "return" => KeyCode::Enter, + "tab" => KeyCode::Tab, + "backtab" => KeyCode::BackTab, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "backspace" => KeyCode::Backspace, + "delete" => KeyCode::Delete, + "space" => KeyCode::Char(' '), + name => { + let function = name + .strip_prefix('f') + .and_then(|number| number.parse::().ok()); + match function { + Some(number @ 1..=24) => KeyCode::F(number), + _ => { + return Err(format!( + "unknown key {remaining:?}; use one character, a named key or f1..f24" + )); + } + } + } + }, + }; + Ok(Self { code, modifiers }.normalized()) + } + + fn label(self) -> String { + let mut text = String::new(); + for (flag, name) in [ + (KeyModifiers::CONTROL, "Ctrl+"), + (KeyModifiers::ALT, "Alt+"), + (KeyModifiers::SUPER, "Super+"), + (KeyModifiers::SHIFT, "Shift+"), + ] { + if self.modifiers.contains(flag) { + text.push_str(name); + } + } + match self.code { + KeyCode::Char(' ') => text.push_str("Space"), + KeyCode::Char(character) => text.push(character), + KeyCode::F(number) => { + let _ = write!(text, "F{number}"); + } + code => text.push_str(match code { + KeyCode::Esc => "Esc", + KeyCode::Enter => "Enter", + KeyCode::Tab => "Tab", + KeyCode::Up => "↑", + KeyCode::Down => "↓", + KeyCode::Left => "←", + KeyCode::Right => "→", + KeyCode::Home => "Home", + KeyCode::End => "End", + KeyCode::PageUp => "PgUp", + KeyCode::PageDown => "PgDn", + KeyCode::Backspace => "Backspace", + KeyCode::Delete => "Del", + _ => unreachable!("only supported normalized keys have labels"), + }), + } + text + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Bindings { + labels: Vec>, + contexts: [Vec<(Key, Action)>; 9], +} + +impl Default for Bindings { + fn default() -> Self { + Self::build(default_keys()).expect("the default key registry must be valid") + } +} + +fn default_keys() -> Vec> { + DEFINITIONS + .iter() + .map(|definition| { + definition + .defaults + .iter() + .map(|key| Key::parse(key).expect("default key descriptions must be valid")) + .collect() + }) + .collect() +} + +fn invalid(message: impl Into) -> io::Error { + io::Error::new(io::ErrorKind::InvalidData, message.into()) +} + +impl Bindings { + pub fn from_toml(item: Option<&Item>) -> io::Result { + let mut keys = default_keys(); + if let Some(item) = item { + let mut overridden = vec![false; DEFINITIONS.len()]; + Self::read_overrides(item, "", &mut keys, &mut overridden)?; + } + Self::build(keys) + } + + fn read_overrides( + item: &Item, + path: &str, + keys: &mut [Vec], + overridden: &mut [bool], + ) -> io::Result<()> { + if let Some(definition) = DEFINITIONS + .iter() + .find(|definition| definition.name == path) + { + if std::mem::replace(&mut overridden[definition.action as usize], true) { + return Err(invalid(format!( + "keys.{path}: duplicate action override; use only one quoted or nested dotted path for this action" + ))); + } + let mut aliases = Vec::new(); + let mut add = |description: &str| -> io::Result<()> { + let key = Key::parse(description).map_err(|error| { + invalid(format!( + "keys.{path}: invalid binding {description:?}: {error}" + )) + })?; + if !aliases.contains(&key) { + aliases.push(key); + } + Ok(()) + }; + if let Some(description) = item.as_str() { + add(description)?; + } else if let Some(array) = item.as_array() { + for (index, value) in array.iter().enumerate() { + let description = value.as_str().ok_or_else(|| { + invalid(format!( + "keys.{path}[{index}]: expected a key description string" + )) + })?; + add(description)?; + } + } else { + return Err(invalid(format!( + "keys.{path}: expected a string or array of strings; use [] to disable" + ))); + } + keys[definition.action as usize] = aliases; + return Ok(()); + } + if !path.is_empty() + && !DEFINITIONS.iter().any(|definition| { + definition + .name + .strip_prefix(path) + .is_some_and(|suffix| suffix.starts_with('.')) + }) + { + return Err(invalid(format!( + "keys.{path}: unknown action or action group; consult --default-config for valid paths" + ))); + } + let table = item.as_table_like().ok_or_else(|| { + invalid(format!( + "keys{}: expected a table of action bindings", + if path.is_empty() { + String::new() + } else { + format!(".{path}") + } + )) + })?; + for (name, value) in table.iter() { + let child = if path.is_empty() { + name.to_owned() + } else { + format!("{path}.{name}") + }; + Self::read_overrides(value, &child, keys, overridden)?; + } + Ok(()) + } + + fn build(keys: Vec>) -> io::Result { + let mut contexts: [Vec<(Key, Action)>; 9] = std::array::from_fn(|_| Vec::new()); + for definition in DEFINITIONS { + for &context in definition.contexts { + let entries = &mut contexts[context as usize]; + for &key in &keys[definition.action as usize] { + if let Some((_, previous)) = entries.iter_mut().find(|(bound, _)| *bound == key) + { + *previous = overlap_winner(context, *previous, definition.action).ok_or_else(|| { + invalid(format!( + "keys: binding {} collides in {context:?} between {} and {}; rebind or disable one action", + key.label(), DEFINITIONS[*previous as usize].name, definition.name, + )) + })?; + } else { + entries.push((key, definition.action)); + } + } + } + } + Ok(Self { + labels: keys + .into_iter() + .map(|aliases| aliases.into_iter().map(Key::label).collect()) + .collect(), + contexts, + }) + } + + pub fn resolve(&self, context: Context, event: KeyEvent) -> Option { + let key = Key { + code: event.code, + modifiers: event.modifiers, + } + .normalized(); + self.contexts[context as usize] + .iter() + .find_map(|(bound, action)| (*bound == key).then_some(*action)) + } + + pub fn label(&self, action: Action) -> &str { + self.labels[action as usize] + .first() + .map_or("unbound", String::as_str) + } + + pub fn help_text(&self) -> String { + let mut text = String::from("Keyboard bindings\n\n"); + for definition in DEFINITIONS { + let aliases = &self.labels[definition.action as usize]; + let bindings = if aliases.is_empty() { + "unbound".to_owned() + } else { + aliases.join(", ") + }; + let _ = write!( + text, + "{}: {} — {} [", + definition.name, bindings, definition.description + ); + for (index, context) in definition.contexts.iter().enumerate() { + if index != 0 { + text.push_str(", "); + } + text.push_str(match context { + Context::Normal => "video list", + Context::Info => "video overview", + Context::Description => "video description", + Context::SearchInput => "search input", + Context::SelectorNormal => "category/channel picker", + Context::SelectorInput => "picker filter input", + Context::TagList => "category tag picker", + Context::TagInput => "new tag input", + Context::Help => "help", + }); + } + text.push_str("]\n"); + } + text.push_str("\nText input inserts literal characters; normal-mode commands do not run there.\n\ +Shared overview/description bindings cycle overview → description → overview; distinct bindings open either view directly.\n\ +Preview close takes priority over history back. Description scrolling takes priority over video selection on shared bindings.\n"); + text + } +} + +fn overlap_winner(context: Context, first: Action, second: Action) -> Option { + use Action::*; + let pair = |a, b| (first == a && second == b) || (first == b && second == a); + if first == second { + return Some(first); + } + if pair(InfoOverview, InfoDescription) && VIEW.contains(&context) { + return Some(if context == Context::Info { + InfoDescription + } else { + InfoOverview + }); + } + if pair(InfoHide, ViewPrevious) && matches!(context, Context::Info | Context::Description) { + return Some(InfoHide); + } + if context == Context::Description { + if pair(DescriptionNext, VideoNext) { + return Some(DescriptionNext); + } + if pair(DescriptionPrevious, VideoPrevious) { + return Some(DescriptionPrevious); + } + } + None +} + +pub fn default_config() -> String { + let mut text = String::from( + "[settings]\npinned_categories = []\n\n[channels]\n\n[categories]\n\n[keys]\n", + ); + for definition in DEFINITIONS { + let mut aliases = Array::new(); + for &key in definition.defaults { + aliases.push(key); + } + let _ = writeln!(text, "# {}", definition.description); + let _ = writeln!(text, "{} = {}", definition.name, aliases); + } + text +} + +#[cfg(test)] +mod tests { + use super::*; + use toml_edit::DocumentMut; + + fn configured(source: &str) -> io::Result { + let document = source.parse::().unwrap(); + Bindings::from_toml(document.get("keys")) + } + + fn press( + bindings: &Bindings, + context: Context, + code: KeyCode, + modifiers: KeyModifiers, + ) -> Option { + bindings.resolve(context, KeyEvent::new(code, modifiers)) + } + + #[test] + fn contextual_cycle_and_direct_preview_bindings() { + let defaults = Bindings::default(); + for (context, action) in [ + (Context::Normal, Action::InfoOverview), + (Context::Info, Action::InfoDescription), + (Context::Description, Action::InfoOverview), + ] { + assert_eq!( + press(&defaults, context, KeyCode::Char('d'), KeyModifiers::NONE), + Some(action) + ); + } + let bindings = + configured("[keys]\nvideo.info.overview = 'I'\nvideo.info.description = 'd'\n") + .unwrap(); + for context in VIEW { + assert_eq!( + press(&bindings, *context, KeyCode::Char('I'), KeyModifiers::NONE), + Some(Action::InfoOverview) + ); + assert_eq!( + press(&bindings, *context, KeyCode::Char('d'), KeyModifiers::NONE), + Some(Action::InfoDescription) + ); + } + assert_eq!( + press(&defaults, Context::Info, KeyCode::Esc, KeyModifiers::NONE), + Some(Action::InfoHide) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Char('-'), + KeyModifiers::NONE + ), + Some(Action::ViewPrevious) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Down, + KeyModifiers::NONE + ), + Some(Action::DescriptionNext) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Char('j'), + KeyModifiers::NONE + ), + Some(Action::VideoNext) + ); + assert_eq!( + press( + &defaults, + Context::Description, + KeyCode::Char('j'), + KeyModifiers::CONTROL + ), + Some(Action::DescriptionNext) + ); + assert_eq!( + press( + &defaults, + Context::SearchInput, + KeyCode::Char('d'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &defaults, + Context::SelectorNormal, + KeyCode::Char('j'), + KeyModifiers::NONE + ), + Some(Action::SelectorNext) + ); + assert_eq!( + press( + &defaults, + Context::SelectorInput, + KeyCode::Char('j'), + KeyModifiers::NONE + ), + None + ); + for context in ALL { + assert_eq!( + press( + &defaults, + *context, + KeyCode::Char('c'), + KeyModifiers::CONTROL + ), + Some(Action::Interrupt) + ); + } + } + + #[test] + fn modifier_normalization_is_exact_and_allocation_free() { + let bindings = configured("[keys]\nvideo.info.overview = 'shift+i'\nview.history.next = 'control++'\nview.refresh = 'option+F24'\nsearch.edit = 'command+界'\n").unwrap(); + for (code, modifiers) in [ + (KeyCode::Char('I'), KeyModifiers::NONE), + (KeyCode::Char('I'), KeyModifiers::SHIFT), + (KeyCode::Char('i'), KeyModifiers::SHIFT), + ] { + assert_eq!( + press(&bindings, Context::Normal, code, modifiers), + Some(Action::InfoOverview) + ); + } + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('i'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('I'), + KeyModifiers::ALT + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('?'), + KeyModifiers::SHIFT + ), + Some(Action::Help) + ); + for (code, modifiers) in [ + (KeyCode::BackTab, KeyModifiers::NONE), + (KeyCode::BackTab, KeyModifiers::SHIFT), + (KeyCode::Tab, KeyModifiers::SHIFT), + ] { + assert_eq!( + press(&bindings, Context::Normal, code, modifiers), + Some(Action::ViewTabPrevious) + ); + } + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('+'), + KeyModifiers::CONTROL + ), + Some(Action::ViewNext) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('+'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::F(24), + KeyModifiers::ALT + ), + Some(Action::ViewRefresh) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('界'), + KeyModifiers::SUPER + ), + Some(Action::SearchEdit) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('c'), + KeyModifiers::CONTROL | KeyModifiers::ALT + ), + None + ); + } + + #[test] + fn aliases_replace_defaults_and_can_disable_actions() { + let bindings = configured("[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); + let quoted = configured("[keys]\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); + assert_eq!(quoted, bindings); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('r'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('q'), + KeyModifiers::NONE + ), + None + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('x'), + KeyModifiers::NONE + ), + Some(Action::ViewRefresh) + ); + assert_eq!( + press( + &bindings, + Context::Normal, + KeyCode::Char('y'), + KeyModifiers::CONTROL + ), + Some(Action::ViewRefresh) + ); + } + + #[test] + fn invalid_entries_and_collisions_have_actionable_errors() { + for (source, path) in [ + ("[keys]\nunknown = 'x'", "keys.unknown"), + ("[keys.unknown]", "keys.unknown"), + ("keys = []", "keys"), + ("[keys]\nview = 'x'", "keys.view"), + ("[keys]\nview.refresh = 1", "keys.view.refresh"), + ("[keys]\nview.refresh = ['x', 1]", "keys.view.refresh[1]"), + ("[keys]\nview.refresh = 'hyper+x'", "keys.view.refresh"), + ( + "[keys]\nview.refresh = 'ctrl+control+x'", + "keys.view.refresh", + ), + ("[keys]\nview.refresh = 'ctrl+'", "keys.view.refresh"), + ("[keys]\nview.refresh = 'f25'", "keys.view.refresh"), + ("[keys]\nview.refresh = ''", "keys.view.refresh"), + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!(error.to_string().contains(path), "{error}"); + } + for source in [ + "[keys]\n'video.info.overview' = 'I'\nvideo.info.overview = 'o'", + "[keys]\nvideo.info.overview = 'o'\n'video.info.overview' = 'I'", + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + assert!( + error.to_string().contains("keys.video.info.overview"), + "{error}" + ); + } + for source in [ + "[keys]\nview.refresh = 'q'", + "[keys]\nview.refresh = 'ctrl+c'", + "[keys]\nsearch.edit = 'shift+g'", + ] { + let error = configured(source).unwrap_err(); + assert_eq!(error.kind(), io::ErrorKind::InvalidData); + } + let collision = configured("[keys]\nview.refresh = 'q'") + .unwrap_err() + .to_string(); + assert!(collision.contains("view.refresh") && collision.contains("app.quit")); + // Disjoint contexts can use the same key, including an overridden input command. + assert!(configured("[keys]\ninput.submit = 'q'").is_ok()); + } +} diff --git a/src/main.rs b/src/main.rs index 8cb37fa..14a56e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ //! A Ratatui YouTube browser backed by yt-dlp, with playback handed to Terebi. //! //! Run `cargo run -- "rust programming"`; default to the installed Terebi CLI on PATH. +//! Key instructions below describe defaults. `[keys]` configures all actions; live hints use overrides. +//! `--default-config` prints complete TOML without loading config or starting the terminal. //! Always start in normal mode; a CLI query searches automatically. Press i to edit the query. //! Insert: Enter submits a changed query and returns to normal; Esc returns without searching. //! Search and new-tag text: Option/Alt+Backspace or Ctrl-W deletes the previous whitespace word. @@ -52,7 +54,7 @@ //! The suffix stays dimmed when selected and counts toward whole-label wrapping without changing category names. //! Browsing a pinned category focuses its existing tab and removes the temporary tab. //! `[settings].pinned_categories = ["Favorites"]` persists pins; missing category names are ignored. -//! Settings, channels and categories tables can appear in any order. Move legacy root pins into settings. +//! Settings, channels, categories and keys tables can appear in any order. Move legacy root pins into settings. //! Tab/Shift-Tab cycle only visible tabs; hidden views retain cached rows and selection. //! In normal mode Esc or minus goes back through visited views, restoring cached rows and selection. //! Plus goes forward after going back; a new view navigation discards the old forward branch. @@ -85,6 +87,7 @@ //! Quitting cancels pending metadata/launch processes, not the separately running player. mod config; +mod keys; mod thumbnail; mod youtube; @@ -97,6 +100,7 @@ use std::{ }; use clap::Parser; +use keys::{Action, Context}; use ratatui::{ DefaultTerminal, Frame, crossterm::{ @@ -115,13 +119,17 @@ use youtube::{Job, PAGE_SIZE, Page, PlayerLaunch, Video}; #[command( version, about = "Browse YouTube and open selected videos in Terebi", - after_help = "Requires 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 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.\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 and categories 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.\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 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 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.\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.\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 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; accepts --new-instance --url followed by a YouTube video URL. #[arg(long, default_value = "terebi")] player: OsString, + /// Print the complete default TOML configuration and exit. + #[arg(long, conflicts_with = "query")] + default_config: bool, + /// Optional initial YouTube search. query: Option, } @@ -324,62 +332,179 @@ enum PreviewMode { Description, } -const KEYBINDINGS_HELP: &str = r#" -VIEWS AND VIDEOS -j / k or arrows Select next / previous video -Enter Play the selected video in Terebi -i Edit the search query -d Info → full description → info -U (Shift-U) Browse the selected video's channel -u Choose a saved channel -c Choose a saved category -s Edit the selected channel's tags -S (Shift-S) Edit all channels in a category -Tab / Shift-Tab Cycle visible tabs -Esc / - Back (Esc closes popups first) -+ Forward after going back -g / Home First loaded video -G / End Last loaded video -r Reload config and refresh -? Open / close this reference -q / Ctrl-C Quit +struct KeyHints { + footers: [(Context, String); 9], + preview: [String; 2], + browser: [String; 2], + tags: [String; 2], + tags_empty: String, + help: String, +} -FULL DESCRIPTION -Arrows, Ctrl-J/K Scroll lines -PageDown / PageUp Scroll pages -j / k Change the selected video -d Return to video info -Esc Close the preview +impl KeyHints { + fn new(bindings: &keys::Bindings) -> Self { + let key = |action| bindings.label(action); + Self { + footers: [ + ( + Context::Normal, + format!( + "NORMAL · {}/{} select · {} play · {} search · {} help · {} quit", + key(Action::VideoNext), + key(Action::VideoPrevious), + key(Action::VideoPlay), + key(Action::SearchEdit), + key(Action::Help), + key(Action::Quit) + ), + ), + ( + Context::Info, + format!( + "INFO · {} description · {} close · {} help", + key(Action::InfoDescription), + key(Action::InfoHide), + key(Action::Help) + ), + ), + ( + Context::Description, + format!( + "DESCRIPTION · {}/{} scroll · {} info · {} close · {} help", + key(Action::DescriptionNext), + key(Action::DescriptionPrevious), + key(Action::InfoOverview), + key(Action::InfoHide), + key(Action::Help) + ), + ), + ( + Context::SearchInput, + format!( + "INSERT · {} search · {} normal · {} quit", + key(Action::InputSubmit), + key(Action::InputCancel), + key(Action::Interrupt) + ), + ), + ( + Context::SelectorNormal, + format!( + "BROWSE · {}/{} move · {} filter · {} browse · {} close · {} help", + key(Action::SelectorNext), + key(Action::SelectorPrevious), + key(Action::SelectorEdit), + key(Action::SelectorOpen), + key(Action::SelectorClose), + key(Action::Help) + ), + ), + ( + Context::SelectorInput, + format!( + "FILTER INSERT · {}/{} move · {} browse · {} normal", + key(Action::SelectorInputNext), + key(Action::SelectorInputPrevious), + key(Action::InputSubmit), + key(Action::InputCancel) + ), + ), + ( + Context::TagList, + format!( + "TAGS · {} toggle · {} save · {} cancel · {} help", + key(Action::TagToggle), + key(Action::TagSave), + key(Action::TagCancel), + key(Action::Help) + ), + ), + ( + Context::TagInput, + format!( + "NEW TAG · {} stage · {} cancel · {} quit", + key(Action::InputSubmit), + key(Action::InputCancel), + key(Action::Interrupt) + ), + ), + ( + Context::Help, + format!( + "HELP · {}/{} scroll · {}/{} page · {}/{} close", + key(Action::HelpNext), + key(Action::HelpPrevious), + key(Action::HelpPageNext), + key(Action::HelpPagePrevious), + key(Action::Help), + key(Action::HelpClose) + ), + ), + ], + preview: [ + format!( + "· {} description · {} close ", + key(Action::InfoDescription), + key(Action::InfoHide) + ), + format!( + "· {}/{} scroll · {} info · {} close ", + key(Action::DescriptionNext), + key(Action::DescriptionPrevious), + key(Action::InfoOverview), + key(Action::InfoHide) + ), + ], + browser: [ + format!( + " Categories · {} filter · {} browse · {} pin ", + key(Action::SelectorEdit), + key(Action::SelectorOpen), + key(Action::SelectorPin) + ), + format!( + " Channels · {} filter · {} browse ", + key(Action::SelectorEdit), + key(Action::SelectorOpen) + ), + ], + tags: [ + format!( + "{}/{} move · {} toggle · {} save · {} new · {} cancel", + key(Action::TagNext), + key(Action::TagPrevious), + key(Action::TagToggle), + key(Action::TagSave), + key(Action::TagNew), + key(Action::TagCancel) + ), + format!( + "{} create · {} back · {} clear", + key(Action::InputSubmit), + key(Action::InputCancel), + key(Action::DeleteLine) + ), + ], + tags_empty: format!("No tags. Press {} to create one.", key(Action::TagNew)), + help: format!( + " Keybindings · {}/{} close · {}/{} scroll ", + key(Action::Help), + key(Action::HelpClose), + key(Action::HelpNext), + key(Action::HelpPrevious) + ), + } + } -CHANNEL / CATEGORY SELECTOR -j / k or arrows Move in NORMAL mode -i Enter filter INSERT mode -Arrows, Ctrl-J/K Move while filtering -Enter Browse the selected match -p Pin / unpin a category (NORMAL) -Esc INSERT → NORMAL → close - -CHANNEL TAGS -j / k or arrows Move between tags -Space Toggle a tag -n Enter a new tag name -Enter Save tags; in name input, stage tag -Esc Cancel; in name input, abandon name - -TEXT INPUT -Enter Submit; search returns to NORMAL -Esc Leave INSERT / abandon tag name -Backspace Delete one character -Alt-Backspace Delete previous word (or Ctrl-W) -Cmd-Backspace Clear the line (or Ctrl-U) -? / + / - Ordinary characters while typing - -HELP WINDOW -j / k or arrows Scroll lines (also Ctrl-J/K) -PageDown / PageUp Scroll pages -? / Esc Close help without changing view -Ctrl-C Quit from any mode or popup -"#; + fn footer(&self, context: Context) -> &str { + self.footers + .iter() + .find(|(candidate, _)| *candidate == context) + .expect("every input context has a hint") + .1 + .as_str() + } +} #[derive(Default)] struct HelpPopup { @@ -396,6 +521,7 @@ struct App { submitted_query: String, config_path: PathBuf, config: config::Config, + key_hints: KeyHints, feed_channels: Vec, views: Vec, view_index: usize, @@ -423,6 +549,7 @@ impl App { fn new(options: Options, config_path: PathBuf, config: config::Config) -> Self { let query = options.query.clone().unwrap_or_default(); let feed_channels = config.subscriptions(); + let key_hints = KeyHints::new(&config.keys); let mut views = vec![ View::new("Search".into(), ViewSource::Search), View::new("Feed".into(), ViewSource::Feed), @@ -440,6 +567,7 @@ impl App { submitted_query: String::new(), config_path, config, + key_hints, feed_channels, views, view_index: 0, @@ -459,7 +587,7 @@ impl App { description_viewport: None, job: None, player_launch: None, - status: "Press i to enter a query, then Enter to search.".into(), + status: "Enter a query to search YouTube.".into(), } } @@ -536,43 +664,33 @@ impl App { self.browser = Some(browser); } - fn browse_key(&mut self, key: KeyEvent) { + fn browse_key(&mut self, key: KeyEvent, action: Option) { let mut browser = self.browser.take().expect("browser is open"); - if browser.mode == Mode::Insert { - match key.code { - KeyCode::Esc => browser.mode = Mode::Normal, - KeyCode::Enter | KeyCode::Up | KeyCode::Down => {} - KeyCode::Char('j' | 'k') if key.modifiers.contains(KeyModifiers::CONTROL) => {} - _ if delete_input(&mut browser.query, key) => { - browser.refilter(); - self.browser = Some(browser); - return; - } - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - browser.query.push(c); - browser.refilter(); + let action = if browser.mode == Mode::Insert { + match action { + Some(Action::InputCancel) => { + browser.mode = Mode::Normal; self.browser = Some(browser); return; } + Some(Action::InputSubmit) => Some(Action::SelectorOpen), + Some(Action::SelectorInputNext) => Some(Action::SelectorNext), + Some(Action::SelectorInputPrevious) => Some(Action::SelectorPrevious), _ => { + edit_input(&mut browser.query, key, action); + browser.refilter(); self.browser = Some(browser); return; } } - if key.code == KeyCode::Esc { - self.browser = Some(browser); - return; - } - } + } else { + action + }; let count = browser.matches.len(); - match key.code { - KeyCode::Esc => return, - KeyCode::Char('i') if browser.mode == Mode::Normal => browser.mode = Mode::Insert, - KeyCode::Down | KeyCode::Char('j') if count != 0 => { + match action { + Some(Action::SelectorClose) => return, + Some(Action::SelectorEdit) => browser.mode = Mode::Insert, + Some(Action::SelectorNext) if count != 0 => { browser.selection.select(Some( browser .selection @@ -582,15 +700,14 @@ impl App { .min(count - 1), )); } - KeyCode::Up | KeyCode::Char('k') if count != 0 => { + Some(Action::SelectorPrevious) if count != 0 => { browser.selection.select(Some( browser.selection.selected().unwrap_or(0).saturating_sub(1), )); } - KeyCode::Enter | KeyCode::Char('p') - if key.code == KeyCode::Enter - || (browser.kind == BrowserKind::Categories - && browser.mode == Mode::Normal) => + Some(Action::SelectorOpen | Action::SelectorPin) + if action == Some(Action::SelectorOpen) + || browser.kind == BrowserKind::Categories => { if let Some((name, source)) = browser .selection @@ -599,7 +716,7 @@ impl App { .and_then(|index| browser.choices.get(*index)) .cloned() { - if key.code == KeyCode::Enter { + if action == Some(Action::SelectorOpen) { self.browse_view(name, source); return; } @@ -724,7 +841,7 @@ impl App { fn search(&mut self) { let query = self.query.trim(); if query.is_empty() { - self.status = "Press i to enter a query, then Enter to search.".into(); + self.status = "Enter a query to search YouTube.".into(); return; } if query != self.submitted_query { @@ -806,7 +923,7 @@ impl App { let page = (view.next_start - 1) / PAGE_SIZE + 1; let result = if view.source == ViewSource::Search { if self.submitted_query.is_empty() { - self.status = "Press i to enter a query, then Enter to search.".into(); + self.status = "Enter a query to search YouTube.".into(); return; } self.status = format!("Searching for {} (page {page})...", self.submitted_query); @@ -817,7 +934,7 @@ impl App { view.loaded = true; view.has_more = false; self.status = format!( - "{} has no channels. Press s on a search result to add one.", + "{} has no channels. Add channels from search results or edit config.", view.name ); return; @@ -842,7 +959,7 @@ impl App { let view = &self.views[self.view_index]; if let Some(error) = &view.page_error { return format!( - "{} videos kept in {}. Page {} failed: {error}. Press r to refresh.", + "{} videos kept in {}. Page {} failed: {error}. Refresh this view to retry.", view.videos.len(), view.name, (view.next_start - 1) / PAGE_SIZE + 1 @@ -855,7 +972,7 @@ impl App { .is_empty() { return format!( - "{} has no channels. Press s on a search result to add one.", + "{} has no channels. Add channels from search results or edit config.", view.name ); } @@ -905,10 +1022,18 @@ impl App { } fn apply_config(&mut self, config: config::Config) { + if config.keys != self.config.keys { + self.key_hints = KeyHints::new(&config.keys); + if let Some(help) = &mut self.help_popup { + help.paragraph = Paragraph::new(config.keys.help_text()).wrap(Wrap { trim: false }); + help.viewport = None; + } + } if config.categories == self.config.categories && config.channels == self.config.channels && config.pinned_categories == self.config.pinned_categories { + self.config.keys = config.keys; return; } let active_source = self.views[self.view_index].source.clone(); @@ -1090,18 +1215,18 @@ impl App { }); } - fn category_key(&mut self, key: KeyEvent) { + fn category_key(&mut self, key: KeyEvent, action: Option) { let mut dialog = self .category_dialog .take() .expect("category dialog is open"); if let Some(name) = &mut dialog.name { - match key.code { - KeyCode::Esc => { + match action { + Some(Action::InputCancel) => { dialog.name = None; dialog.error = None; } - KeyCode::Enter => { + Some(Action::InputSubmit) => { let name = name.trim().to_owned(); if name.is_empty() || name.chars().any(char::is_control) { dialog.error = @@ -1117,28 +1242,20 @@ impl App { dialog.error = None; } } - _ if delete_input(name, key) => {} - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - name.push(c); - } - _ => {} + _ => edit_input(name, key, action), } } else { let count = dialog.tags.len(); - match key.code { - KeyCode::Esc => { + match action { + Some(Action::TagCancel) => { self.switch_view(self.view_index); return; } - KeyCode::Char('n') => { + Some(Action::TagNew) => { dialog.name = Some(String::new()); dialog.error = None; } - KeyCode::Char(' ') => { + Some(Action::TagToggle) => { if let Some((_, checked)) = dialog .selection .selected() @@ -1148,7 +1265,7 @@ impl App { dialog.error = None; } } - KeyCode::Down | KeyCode::Char('j') if count != 0 => { + Some(Action::TagNext) if count != 0 => { dialog.selection.select(Some( dialog .selection @@ -1158,12 +1275,12 @@ impl App { .min(count - 1), )); } - KeyCode::Up | KeyCode::Char('k') if count != 0 => { + Some(Action::TagPrevious) if count != 0 => { dialog.selection.select(Some( dialog.selection.selected().unwrap_or(0).saturating_sub(1), )); } - KeyCode::Enter => { + Some(Action::TagSave) => { let result = match &dialog.target { CategoryTarget::Channel { handle, channel } => config::set_channel_tags( &self.config_path, @@ -1306,23 +1423,50 @@ impl App { } } + fn key_context(&self) -> Context { + if self.help_popup.is_some() { + Context::Help + } else if let Some(dialog) = &self.category_dialog { + if dialog.name.is_some() { + Context::TagInput + } else { + Context::TagList + } + } else if let Some(browser) = &self.browser { + if browser.mode == Mode::Insert { + Context::SelectorInput + } else { + Context::SelectorNormal + } + } else if self.mode == Mode::Insert { + Context::SearchInput + } else { + match self.preview { + Some(PreviewMode::Info) => Context::Info, + Some(PreviewMode::Description) => Context::Description, + None => Context::Normal, + } + } + } + fn key(&mut self, key: KeyEvent) -> bool { - if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + let action = self.config.keys.resolve(self.key_context(), key); + if action == Some(Action::Interrupt) { return true; } if let Some(help) = &mut self.help_popup { - match key.code { - KeyCode::Esc | KeyCode::Char('?') => self.help_popup = None, - KeyCode::Char('q') => return true, - KeyCode::Down | KeyCode::Char('j') => { + match action { + Some(Action::HelpClose | Action::Help) => self.help_popup = None, + Some(Action::Quit) => return true, + Some(Action::HelpNext) => { help.scroll = help.scroll.saturating_add(1).min(help.max_scroll); } - KeyCode::Up | KeyCode::Char('k') => help.scroll = help.scroll.saturating_sub(1), - KeyCode::PageDown => { + Some(Action::HelpPrevious) => help.scroll = help.scroll.saturating_sub(1), + Some(Action::HelpPageNext) => { let page = help.viewport.map_or(1, |(_, height)| height.max(1)); help.scroll = help.scroll.saturating_add(page).min(help.max_scroll); } - KeyCode::PageUp => { + Some(Action::HelpPagePrevious) => { let page = help.viewport.map_or(1, |(_, height)| height.max(1)); help.scroll = help.scroll.saturating_sub(page); } @@ -1330,89 +1474,65 @@ impl App { } return false; } - if key.code == KeyCode::Char('?') - && self.mode == Mode::Normal - && self - .browser - .as_ref() - .is_none_or(|browser| browser.mode == Mode::Normal) - && self - .category_dialog - .as_ref() - .is_none_or(|dialog| dialog.name.is_none()) - { + if action == Some(Action::Help) { self.help_popup = Some(HelpPopup { - paragraph: Paragraph::new(KEYBINDINGS_HELP).wrap(Wrap { trim: false }), + paragraph: Paragraph::new(self.config.keys.help_text()).wrap(Wrap { trim: false }), ..Default::default() }); return false; } if self.category_dialog.is_some() { - self.category_key(key); + self.category_key(key, action); return false; } if self.browser.is_some() { - self.browse_key(key); + self.browse_key(key, action); return false; } let selected_before = self.views[self.view_index].selection.selected(); let preview_was_open = self.preview.is_some(); if self.mode == Mode::Insert { - match key.code { - KeyCode::Esc => self.mode = Mode::Normal, - KeyCode::Enter => { + match action { + Some(Action::InputCancel) => self.mode = Mode::Normal, + Some(Action::InputSubmit) => { self.search(); self.mode = Mode::Normal; } - _ if delete_input(&mut self.query, key) => {} - KeyCode::Char(c) - if !key - .modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => - { - self.query.push(c); - } - _ => {} + _ => edit_input(&mut self.query, key, action), } } else { - match key.code { - KeyCode::Char('q') => return true, - KeyCode::Char('i') => { + match action { + Some(Action::Quit) => return true, + Some(Action::SearchEdit) => { self.close_preview(); self.mode = Mode::Insert; } - KeyCode::Char('d') => { - self.preview = match self.preview { - Some(PreviewMode::Info) => Some(PreviewMode::Description), - Some(PreviewMode::Description) => Some(PreviewMode::Info), - None => self.views[self.view_index] - .selection - .selected() - .filter(|&index| index < self.views[self.view_index].videos.len()) - .map(|_| PreviewMode::Info), - }; + Some(action @ (Action::InfoOverview | Action::InfoDescription)) => { + self.preview = self.views[self.view_index] + .selection + .selected() + .filter(|&index| index < self.views[self.view_index].videos.len()) + .map(|_| { + if action == Action::InfoOverview { + PreviewMode::Info + } else { + PreviewMode::Description + } + }); } - KeyCode::Esc if self.preview.is_some() => self.close_preview(), - KeyCode::Esc | KeyCode::Char('-') => self.back_view(), - KeyCode::Char('+') => self.forward_view(), - KeyCode::Down | KeyCode::Char('j') - if self.preview == Some(PreviewMode::Description) - && (key.code == KeyCode::Down - || key.modifiers.contains(KeyModifiers::CONTROL)) => - { + Some(Action::InfoHide) => self.close_preview(), + Some(Action::ViewPrevious) => self.back_view(), + Some(Action::ViewNext) => self.forward_view(), + Some(Action::DescriptionNext) => { self.description_scroll = self .description_scroll .saturating_add(1) .min(self.description_max_scroll); } - KeyCode::Up | KeyCode::Char('k') - if self.preview == Some(PreviewMode::Description) - && (key.code == KeyCode::Up - || key.modifiers.contains(KeyModifiers::CONTROL)) => - { + Some(Action::DescriptionPrevious) => { self.description_scroll = self.description_scroll.saturating_sub(1); } - KeyCode::PageDown if self.preview == Some(PreviewMode::Description) => { + Some(Action::DescriptionPageNext) => { let page = self .description_viewport .map(|(_, height)| height) @@ -1423,7 +1543,7 @@ impl App { .saturating_add(page) .min(self.description_max_scroll); } - KeyCode::PageUp if self.preview == Some(PreviewMode::Description) => { + Some(Action::DescriptionPagePrevious) => { let page = self .description_viewport .map(|(_, height)| height) @@ -1431,27 +1551,21 @@ impl App { .max(1); self.description_scroll = self.description_scroll.saturating_sub(page); } - KeyCode::Char('c') => self.open_browser(BrowserKind::Categories), - KeyCode::Char('u') => self.open_browser(BrowserKind::Channels), - KeyCode::Char('U') => self.open_video_channel(), - KeyCode::Char('s') => self.open_category_picker(), - KeyCode::Char('S') => self.open_bulk_category_picker(), - KeyCode::Char('r') => self.refresh(), - KeyCode::Tab => self.cycle_view(false), - KeyCode::BackTab => self.cycle_view(true), - KeyCode::Enter => self.play(), - KeyCode::Down | KeyCode::Char('j') => { - self.views[self.view_index].selection.select_next() - } - KeyCode::Up | KeyCode::Char('k') => { + Some(Action::CategoryBrowse) => self.open_browser(BrowserKind::Categories), + Some(Action::ChannelBrowse) => self.open_browser(BrowserKind::Channels), + Some(Action::VideoChannel) => self.open_video_channel(), + Some(Action::ChannelTags) => self.open_category_picker(), + Some(Action::CategoryTags) => self.open_bulk_category_picker(), + Some(Action::ViewRefresh) => self.refresh(), + Some(Action::ViewTabNext) => self.cycle_view(false), + Some(Action::ViewTabPrevious) => self.cycle_view(true), + Some(Action::VideoPlay) => self.play(), + Some(Action::VideoNext) => self.views[self.view_index].selection.select_next(), + Some(Action::VideoPrevious) => { self.views[self.view_index].selection.select_previous() } - KeyCode::Home | KeyCode::Char('g') => { - self.views[self.view_index].selection.select_first() - } - KeyCode::End | KeyCode::Char('G') => { - self.views[self.view_index].selection.select_last() - } + Some(Action::VideoFirst) => self.views[self.view_index].selection.select_first(), + Some(Action::VideoLast) => self.views[self.view_index].selection.select_last(), _ => {} } } @@ -1594,31 +1708,7 @@ impl App { .block(Block::bordered().title(" Status ")), status, ); - let help_text = match self.mode { - _ if self.help_popup.is_some() => "HELP · j/k ↑/↓ scroll · PgUp/PgDn · ?/Esc close", - Mode::Insert => "INSERT · Enter search · Esc normal · Ctrl-C quit", - Mode::Normal if self.category_dialog.is_some() => { - if self.category_dialog.as_ref().unwrap().name.is_some() { - "NEW TAG · Enter stage · Esc cancel · Ctrl-C quit" - } else { - "TAGS · Space toggle · Enter save · Esc cancel · ? help" - } - } - Mode::Normal if self.browser.is_some() => { - if self.browser.as_ref().unwrap().mode == Mode::Insert { - "FILTER INSERT · ↑/↓ move · Enter browse · Esc normal" - } else { - "BROWSE · j/k move · i filter · Enter browse · Esc close · ? help" - } - } - Mode::Normal if self.preview == Some(PreviewMode::Description) => { - "DESCRIPTION · ↑/↓ scroll · d info · Esc close · ? help" - } - Mode::Normal if self.preview == Some(PreviewMode::Info) => { - "INFO · d description · Esc close · ? help" - } - Mode::Normal => "NORMAL · j/k select · Enter play · i search · ? help · q quit", - }; + let help_text = self.key_hints.footer(self.key_context()); frame.render_widget(Paragraph::new(help_text).dim(), help); if let Some(mode) = self.preview && let Some(video) = self.views[self.view_index] @@ -1750,9 +1840,9 @@ impl App { " Description " }, if mode == PreviewMode::Info { - "· d description · Esc close " + self.key_hints.preview[0].as_str() } else { - "· ↑/↓ Ctrl-J/K PgUp/PgDn · d info · Esc close " + self.key_hints.preview[1].as_str() }, (self.description_job.is_some() || (mode == PreviewMode::Info && self.thumbnail.is_loading())) @@ -1772,7 +1862,7 @@ impl App { }; let popup = preview_area(frame.area()); let block = Block::bordered() - .title(" Keybindings · ?/Esc close · ↑/↓ PgUp/PgDn ") + .title(self.key_hints.help.as_str()) .border_style(Style::new().fg(Color::Cyan)); let inner = block.inner(popup).inner(ratatui::layout::Margin::new(1, 0)); let viewport = (inner.width, inner.height); @@ -1802,8 +1892,8 @@ impl App { frame.render_widget(Clear, popup); let block = Block::bordered() .title(match browser.kind { - BrowserKind::Categories => " Categories · i filter · Enter browse · p pin ", - BrowserKind::Channels => " Channels · i filter · Enter browse ", + BrowserKind::Categories => self.key_hints.browser[0].as_str(), + BrowserKind::Channels => self.key_hints.browser[1].as_str(), }) .border_style(Style::new().fg(Color::Cyan)); let [input, list, error] = Layout::vertical([ @@ -1951,7 +2041,7 @@ impl App { frame.set_cursor_position((body.x + cursor.min(body.width - 1), body.y)); } } else if dialog.tags.is_empty() { - frame.render_widget(Paragraph::new("No tags. Press n to create one."), body); + frame.render_widget(Paragraph::new(self.key_hints.tags_empty.as_str()), body); } else { frame.render_stateful_widget( List::new(dialog.tags.iter().map(|(name, checked)| { @@ -1976,9 +2066,9 @@ impl App { } frame.render_widget( Paragraph::new(if dialog.name.is_some() { - "Enter create · Esc back · Ctrl-U clear" + self.key_hints.tags[1].as_str() } else { - "j/k move · Space toggle · Enter save · n new · Esc cancel" + self.key_hints.tags[0].as_str() }) .dim(), help, @@ -2013,11 +2103,7 @@ impl App { } } -fn loading_title<'a>( - title: &'a str, - controls: &'static str, - spinner: Option<&'static str>, -) -> Line<'a> { +fn loading_title<'a>(title: &'a str, controls: &'a str, spinner: Option<&'static str>) -> Line<'a> { let mut spans = Vec::with_capacity(1 + usize::from(!controls.is_empty()) + usize::from(spinner.is_some())); spans.push(title.into()); @@ -2049,17 +2135,10 @@ fn preview_area(area: Rect) -> Rect { ) } -fn delete_input(text: &mut String, key: KeyEvent) -> bool { - match key.code { - KeyCode::Backspace if key.modifiers.contains(KeyModifiers::SUPER) => text.clear(), - KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => text.clear(), - KeyCode::Backspace | KeyCode::Char('w') - if key.modifiers.contains(if key.code == KeyCode::Backspace { - KeyModifiers::ALT - } else { - KeyModifiers::CONTROL - }) => - { +fn edit_input(text: &mut String, key: KeyEvent, action: Option) { + match action { + Some(Action::DeleteLine) => text.clear(), + Some(Action::DeleteWord) => { let keep = text .char_indices() .rev() @@ -2068,16 +2147,30 @@ fn delete_input(text: &mut String, key: KeyEvent) -> bool { .map_or(0, |(index, c)| index + c.len_utf8()); text.truncate(keep); } - KeyCode::Backspace => { + Some(Action::DeleteCharacter) => { text.pop(); } - _ => return false, + None => { + if let KeyCode::Char(c) = key.code + && !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) + { + text.push(c); + } + } + _ => {} } - true } fn main() -> io::Result<()> { let options = Options::parse(); + if options.default_config { + return std::io::Write::write_all( + &mut io::stdout().lock(), + keys::default_config().as_bytes(), + ); + } let config_path = config::config_path()?; let config = config::load(&config_path)?; let app = App::new(options, config_path, config); @@ -2117,6 +2210,161 @@ mod tests { app.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); } + #[test] + fn remapped_preview_and_input_actions_survive_key_only_config_reload() { + use ratatui::{Terminal, backend::TestBackend}; + let mut app = app(&["tubular"]); + app.views[0].videos = youtube::search_results( + r#"{"entries":[{"id":"jNQXAC9IVRw","title":"First"},{"id":"rQ_J9WH6CGk","title":"Second"}]}"#, + ).unwrap().videos; + // Invalid IDs avoid curl; full descriptions exercise the real preview without extraction. + for (index, id) in ["first", "second"].into_iter().enumerate() { + app.views[0].videos[index].id = id.into(); + app.remember_description(id.into(), "A long description paragraph.\n\n".repeat(40)); + } + app.views[0].loaded = true; + app.views[0].selection.select(Some(0)); + app.views[0].next_start = 41; + let document = r#"[keys] +video.info.overview = "I" +video.info.hide = "x" +search.edit = "f2" +input.delete.word = "ctrl+backspace" +input.delete.line = "ctrl+l" +"# + .parse::() + .unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers)); + press(&mut app, KeyCode::Char('d'), KeyModifiers::NONE); + assert_eq!(app.preview, Some(PreviewMode::Description)); + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal + .draw(|frame| app.draw(frame, Duration::ZERO)) + .unwrap(); + press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); + assert_eq!(app.description_scroll, 1); + press(&mut app, KeyCode::Char('I'), KeyModifiers::SHIFT); + assert_eq!(app.preview, Some(PreviewMode::Info)); + press(&mut app, KeyCode::Char('x'), KeyModifiers::NONE); + assert_eq!(app.preview, None); + assert!(app.description_job.is_none()); + let remap = "[keys]\nvideo.select.next = 'n'\n" + .parse::() + .unwrap(); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(remap.get("keys")).unwrap(), + ..Default::default() + }); + assert_eq!(app.views[0].next_start, 41); + assert_eq!(app.views[0].videos[0].title, "First"); + press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); + assert_eq!(app.views[0].selection.selected(), Some(0)); + press(&mut app, KeyCode::Char('n'), KeyModifiers::NONE); + assert_eq!(app.views[0].selection.selected(), Some(1)); + app.apply_config(config::Config { + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + press(&mut app, KeyCode::Char('i'), KeyModifiers::NONE); + assert_eq!(app.mode, Mode::Normal); + press(&mut app, KeyCode::F(2), KeyModifiers::NONE); + for character in "red blue I d?".chars() { + press(&mut app, KeyCode::Char(character), KeyModifiers::NONE); + } + assert_eq!(app.query, "red blue I d?"); + assert_eq!(app.preview, None); + press(&mut app, KeyCode::Backspace, KeyModifiers::CONTROL); + assert_eq!(app.query, "red blue I "); + press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); + assert_eq!(app.query, "red blue I "); + press(&mut app, KeyCode::Char('l'), KeyModifiers::CONTROL); + assert_eq!(app.query, ""); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(app.mode, Mode::Normal); + } + + #[test] + fn remapped_selector_and_tag_keys_keep_input_and_modal_navigation_separate() { + let mut app = app(&["tubular"]); + let document = r#"[keys] +selector.select.next = "l" +selector.edit = "f3" +selector.close = "z" +input.submit = "ctrl+e" +input.cancel = "ctrl+b" +tags.new = "f4" +tags.cancel = "z" +"# + .parse::() + .unwrap(); + app.apply_config(config::Config { + channels: [ + ("@Alpha".into(), "alpha".into()), + ("@Beta".into(), "beta".into()), + ] + .into(), + keys: keys::Bindings::from_toml(document.get("keys")).unwrap(), + ..Default::default() + }); + loaded_channel_views(&mut app); + let press = |app: &mut App, code, modifiers| app.key(KeyEvent::new(code, modifiers)); + app.open_browser(BrowserKind::Channels); + press(&mut app, KeyCode::Char('j'), KeyModifiers::NONE); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(0)); + press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + press(&mut app, KeyCode::F(3), KeyModifiers::NONE); + press(&mut app, KeyCode::Char('l'), KeyModifiers::NONE); + assert_eq!(app.browser.as_ref().unwrap().query, "l"); + assert_eq!(app.browser.as_ref().unwrap().matches, [0]); + press(&mut app, KeyCode::Char('u'), KeyModifiers::CONTROL); + press(&mut app, KeyCode::Char('j'), KeyModifiers::CONTROL); + assert_eq!(app.browser.as_ref().unwrap().selection.selected(), Some(1)); + press(&mut app, KeyCode::Enter, KeyModifiers::NONE); + assert!(app.browser.is_some()); + press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL); + assert!(app.browser.is_none()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + assert_eq!(app.views[app.view_index].next_start, 41); + let history = app.view_history.clone(); + app.category_dialog = Some(CategoryDialog { + target: CategoryTarget::Channel { + handle: "@Beta".into(), + channel: "beta".into(), + }, + label: "@Beta".into(), + tags: BTreeMap::new(), + selection: ListState::default(), + name: None, + error: None, + }); + press(&mut app, KeyCode::Esc, KeyModifiers::NONE); + assert_eq!(app.view_history, history); + assert!(app.category_dialog.is_some()); + press(&mut app, KeyCode::F(4), KeyModifiers::NONE); + for character in "lI?".chars() { + press(&mut app, KeyCode::Char(character), KeyModifiers::NONE); + } + press(&mut app, KeyCode::Char('e'), KeyModifiers::CONTROL); + assert_eq!( + app.category_dialog.as_ref().unwrap().tags.get("lI?"), + Some(&true) + ); + press(&mut app, KeyCode::Char('z'), KeyModifiers::NONE); + assert!(app.category_dialog.is_none()); + assert_eq!( + app.views[app.view_index].source, + ViewSource::Channel("beta".into()) + ); + } + #[test] fn help_scrolls_without_navigating_and_closes_to_the_same_preview() { use ratatui::{Terminal, backend::TestBackend}; @@ -2599,6 +2847,7 @@ mod tests { fn channel_and_category_share_temporary_tab_and_cached_history() { let mut app = app(&["tubular"]); app.apply_config(config::Config { + keys: Default::default(), channels: [ ("@Alpha".into(), "alpha".into()), ("@Beta".into(), "beta".into()), @@ -2688,6 +2937,7 @@ mod tests { .videos; app.views[app.view_index].page_error = Some("kept".into()); app.apply_config(config::Config { + keys: Default::default(), channels: [ ("@Renamed".into(), "alpha".into()), ("@Beta".into(), "beta".into()), @@ -2717,6 +2967,7 @@ mod tests { assert_eq!(beta.next_start, 41); assert_eq!(beta.selection.selected(), Some(1)); app.apply_config(config::Config { + keys: Default::default(), channels: [("@Beta".into(), "beta".into())].into(), categories: [("@Alpha".into(), vec!["beta".into()])].into(), pinned_categories: vec!["@Alpha".into()], @@ -3770,6 +4021,7 @@ mod tests { let rust = vec!["https://www.youtube.com/@RustVideos".into()]; app.apply_config(config::Config { + keys: Default::default(), categories: [("Rust".into(), rust.clone())].into(), channels: BTreeMap::new(), pinned_categories: vec!["Rust".into()], @@ -3798,6 +4050,7 @@ mod tests { app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); app.apply_config(config::Config { + keys: Default::default(), categories: [ ( "Books".into(), @@ -3814,6 +4067,7 @@ mod tests { assert_eq!(app.views[3].selection.selected(), Some(1)); assert!(app.views[3].loaded); app.apply_config(config::Config { + keys: Default::default(), categories: [("Rust".into(), vec![])].into(), channels: BTreeMap::new(), pinned_categories: Vec::new(), @@ -3834,6 +4088,7 @@ mod tests { Options::parse_from(["tubular", "rust"]), PathBuf::new(), config::Config { + keys: Default::default(), channels: [("@RustVideos".into(), registered.into())].into(), categories: [ ("Direct".into(), vec![direct.into()]), @@ -3874,6 +4129,7 @@ mod tests { app.switch_view(1); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [ ("Direct".into(), vec![direct.into()]), @@ -3907,6 +4163,7 @@ mod tests { assert!(app.views[4].page_error.is_none()); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [ ("Direct".into(), vec![direct.into()]), @@ -3928,6 +4185,7 @@ mod tests { let mut channels = app.config.channels.clone(); channels.insert("@YouTube".into(), added.into()); app.apply_config(config::Config { + keys: Default::default(), channels, categories: app.config.categories.clone(), pinned_categories: Vec::new(), @@ -3949,6 +4207,7 @@ mod tests { let mut channels = app.config.channels.clone(); channels.remove("@YouTube"); app.apply_config(config::Config { + keys: Default::default(), channels, categories: app.config.categories.clone(), pinned_categories: Vec::new(), @@ -3965,6 +4224,7 @@ mod tests { fn builtins_and_same_named_tags_keep_distinct_identities() { let mut app = app(&["tubular", "rust"]); app.apply_config(config::Config { + keys: Default::default(), channels: [( "@RustVideos".into(), "https://www.youtube.com/channel/UCaYhcUwRBNscFNUKTjgPFiA".into(), @@ -3981,6 +4241,7 @@ mod tests { } app.switch_view(1); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [ ("Alpha".into(), vec![]), @@ -3999,6 +4260,7 @@ mod tests { app.switch_view(0); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [("Feed".into(), vec![]), ("Search".into(), vec![])].into(), pinned_categories: Vec::new(), @@ -4006,6 +4268,7 @@ mod tests { assert_eq!(app.view_index, 0); app.switch_view(2); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [ ("Alpha".into(), vec![]), @@ -4020,6 +4283,7 @@ mod tests { app.switch_view(4); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [ ("Alpha".into(), vec![]), @@ -4033,6 +4297,7 @@ mod tests { assert_eq!(app.view_index, 5); assert_eq!(app.views[5].selection.selected(), Some(0)); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [ ("Alpha".into(), vec![]), @@ -4046,6 +4311,7 @@ mod tests { assert_eq!(app.views[0].selection.selected(), Some(1)); app.switch_view(4); app.apply_config(config::Config { + keys: Default::default(), channels: app.config.channels.clone(), categories: [("Alpha".into(), vec![]), ("Beta".into(), vec![])].into(), pinned_categories: Vec::new(),