use std::{fmt::Write as _, io}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ style::Stylize, text::{Line, Span, Text}, }; use toml_edit::{Array, Item}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(usize)] pub enum Context { Normal, Info, Description, CollectionNormal, CollectionInfo, CollectionDescription, SearchInput, SelectorNormal, SelectorInput, TagList, TagFilter, TagInput, CategoryNameInput, Help, } const ALL: &[Context] = &[ Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::SearchInput, Context::SelectorNormal, Context::SelectorInput, Context::TagList, Context::TagFilter, Context::TagInput, Context::CategoryNameInput, Context::Help, ]; const VIEW: &[Context] = &[ Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, ]; const COLLECTION: &[Context] = &[ Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, ]; const TEXT: &[Context] = &[ Context::SearchInput, Context::SelectorInput, Context::TagFilter, Context::TagInput, Context::CategoryNameInput, ]; const NON_TEXT: &[Context] = &[ Context::Normal, Context::Info, Context::Description, Context::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, 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::CollectionNormal, Context::CollectionInfo, Context::CollectionDescription, Context::Help], ["q"]; ProviderChoose, "app.provider", "Choose the browsing provider", &[Context::Normal, Context::CollectionNormal], ["p"]; Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"]; InputSubmit, "input.submit", "Submit search, browse selector, leave tag filter, stage new tag or save category name", 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 general or current-category search", VIEW, ["i"]; ViewSearch, "view.search", "Go to general YouTube Search", VIEW, ["/"]; ViewFeed, "view.feed", "Go to the combined subscription Feed", VIEW, ["F"]; ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-"]; 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 checked videos in check order, or the selected video", VIEW, ["enter"]; VideoEnqueue, "video.enqueue", "Append checked videos in check order, or the selected video, to Terebi's queue", VIEW, ["ctrl+enter"]; VideoOpen, "video.open", "Open the selected YouTube video in the default browser", VIEW, ["o"]; VideoCopyUrl, "video.copy_url", "Copy the selected video's URL to the terminal clipboard", VIEW, ["y"]; 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"]; VideoToggle, "video.select.toggle", "Toggle the selected video's playback check", VIEW, ["space"]; VideoClear, "video.select.clear", "Discard playback checks in the current view", VIEW, ["escape", "ctrl+space"]; 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, Context::CollectionInfo, Context::CollectionDescription], ["escape"]; CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"]; ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"]; VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"]; ChannelPlaylists, "channel.playlists", "Browse the current channel's playlists", &[Context::Normal], ["L"]; ChannelTags, "channel.tags", "Edit the selected YouTube channel's or anime show's category tags", VIEW, ["s"]; CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"]; CategoryRename, "category.rename", "Rename the current or selected category", &[Context::Normal, Context::CollectionNormal, Context::SelectorNormal, Context::TagList], ["R"]; CategorySave, "category.save", "Save the combined category view as a new category", VIEW, ["P"]; WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"]; WatchLaterBrowse, "view.watch_later.browse", "Choose watch later across all categories or one category", VIEW, ["W"]; WatchLaterBrowseCategory, "view.watch_later.category", "Browse the current category's watch later", VIEW, ["alt+w"]; WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal, Context::CollectionNormal], ["f"]; CollectionRemoveSelected, "view.collection.remove.selected", "Remove the selected video from the current collection (also in its previews)", COLLECTION, ["x"]; CollectionRemoveBulk, "view.collection.remove.bulk", "Confirm removal of the current collection subset", COLLECTION, ["X"]; SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"]; SavedVideoBrowse, "view.saved.browse", "Choose saved videos across all categories or one category", VIEW, ["V"]; SavedVideoBrowseCategory, "view.saved.category", "Browse the current category's saved videos", VIEW, ["alt+v"]; WatchingBrowse, "view.watching.browse", "Browse started, unfinished videos", VIEW, ["C"]; WatchStatusWatching, "video.status.watching", "Mark the selected video as watching", VIEW, ["m"]; WatchStatusWatched, "video.status.watched", "Mark the selected video as watched", VIEW, ["M"]; SelectorNext, "selector.select.next", "Select the next category, channel, or playlist", &[Context::SelectorNormal], ["j", "down"]; SelectorPrevious, "selector.select.previous", "Select the previous category, channel, or playlist", &[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, channel, playlist, or collection filter", &[Context::SelectorNormal], ["i"]; SelectorOpen, "selector.open", "Open the selected category, channel, playlist, or collection", &[Context::SelectorNormal], ["enter"]; SelectorPin, "selector.pin", "Toggle category pinning (category picker only)", &[Context::SelectorNormal], ["p"]; SelectorToggle, "selector.toggle", "Check a category for combined browsing (category picker only)", &[Context::SelectorNormal], ["space"]; SelectorClose, "selector.close", "Close the category, channel, playlist, or collection 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"]; TagEdit, "tags.edit", "Edit the category tag filter", &[Context::TagList], ["i"]; TagInputNext, "tags.input.next", "Select the next tag while editing the filter", &[Context::TagFilter], ["down", "ctrl+j"]; TagInputPrevious, "tags.input.previous", "Select the previous tag while editing the filter", &[Context::TagFilter], ["up", "ctrl+k"]; 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, Context::CollectionDescription], ["down", "ctrl+j"]; DescriptionPrevious, "video.description.scroll.previous", "Scroll the description up one line", &[Context::Description, Context::CollectionDescription], ["up", "ctrl+k"]; DescriptionPageNext, "video.description.page.next", "Scroll the description down one page", &[Context::Description, Context::CollectionDescription], ["pagedown"]; DescriptionPagePrevious, "video.description.page.previous", "Scroll the description up one page", &[Context::Description, Context::CollectionDescription], ["pageup"]; HelpNext, "help.scroll.next", "Scroll help down one line", &[Context::Help], ["j", "down", "ctrl+j"]; HelpPrevious, "help.scroll.previous", "Scroll help up one line", &[Context::Help], ["k", "up", "ctrl+k"]; HelpPageNext, "help.page.next", "Scroll help down one page", &[Context::Help], ["pagedown"]; 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)>; ALL.len()], } 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)>; ALL.len()] = 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) -> Text<'static> { use Action::*; type Row = (&'static [Action], &'static str); let sections: &[(&str, &[Row])] = &[ ( "VIEWS AND VIDEOS", &[ (&[VideoNext, VideoPrevious], "Select next / previous video"), ( &[VideoPlay], "Play checked videos in check order, or the selected video", ), ( &[VideoEnqueue], "Append checked videos in check order, or the selected video, to Terebi's queue", ), ( &[VideoToggle], "Check / uncheck the selected video for playback", ), (&[VideoClear], "Clear playback checks in the current view"), ( &[VideoOpen], "Open the selected YouTube video in the default browser", ), (&[VideoCopyUrl], "Copy the selected video's URL"), ( &[WatchStatusWatching], "Mark selected as watching (clear watched)", ), ( &[WatchStatusWatched], "Mark selected as watched (leave watching)", ), (&[SearchEdit], "Edit general or current-category search"), (&[ViewSearch], "Go to general YouTube Search"), (&[ViewFeed], "Go to the combined subscription Feed"), ( &[InfoOverview, InfoDescription], "Info → full description → info", ), (&[VideoChannel], "Browse the selected video's channel"), (&[ChannelBrowse], "Choose a saved channel"), ( &[ChannelPlaylists], "Browse the current channel's playlists", ), (&[CategoryBrowse], "Choose a saved category"), (&[ChannelTags], "Edit the selected channel's tags"), (&[CategoryTags], "Edit all channels in a category"), ( &[CategoryRename], "Rename the current category (including scoped collections)", ), ( &[CategorySave], "Save a combined category view as a new category", ), (&[ViewTabNext, ViewTabPrevious], "Cycle visible tabs"), (&[ViewPrevious], "Back (preview close takes priority)"), (&[ViewNext], "Forward after going back"), (&[VideoFirst], "First loaded video"), (&[VideoLast], "Last loaded video"), (&[ViewRefresh], "Reload config and refresh"), ( &[ProviderChoose], "Choose the browsing provider (NORMAL only)", ), (&[Help], "Open / close this reference"), (&[Quit, Interrupt], "Quit"), ], ), ( "WATCH LATER", &[ (&[WatchLaterSave], "Save the selected video"), ( &[WatchLaterBrowse], "Choose all-categories or category watch later", ), ( &[WatchLaterBrowseCategory], "Browse current category's watch later", ), (&[WatchLaterFilter], "Cycle all / unwatched / watched"), ], ), ( "SAVED VIDEOS", &[ ( &[SavedVideoSave], "Save the selected video (also in previews)", ), ( &[SavedVideoBrowse], "Choose all-categories or category saved videos", ), ( &[SavedVideoBrowseCategory], "Browse current category's saved videos", ), ], ), ( "CURRENTLY WATCHING", &[(&[WatchingBrowse], "Browse started, unfinished videos")], ), ( "COLLECTION LISTS", &[ ( &[CollectionRemoveSelected], "Remove selected from the current collection only", ), ( &[CollectionRemoveBulk], "Confirm removal of current list / category / filtered subset", ), ( &[], "Previews inherit their underlying collection's removal scope", ), ( &[], "Bulk confirmation: y / Enter confirms; n / Esc cancels", ), ], ), ( "FULL DESCRIPTION", &[ (&[DescriptionNext, DescriptionPrevious], "Scroll lines"), ( &[DescriptionPageNext, DescriptionPagePrevious], "Scroll pages", ), (&[VideoNext, VideoPrevious], "Change the selected video"), (&[InfoOverview], "Return to video info"), (&[InfoHide], "Close the preview"), ], ), ( "CHANNEL / CATEGORY / PLAYLIST / COLLECTION SELECTOR", &[ (&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"), (&[SelectorEdit], "Enter filter INSERT mode"), ( &[SelectorInputNext, SelectorInputPrevious], "Move while filtering", ), ( &[SelectorOpen, InputSubmit], "Browse checked categories, or the selected match", ), ( &[SelectorToggle], "Check categories for a combined feed (NORMAL only)", ), ( &[SelectorPin], "Pin / unpin a category (category picker only)", ), ( &[CategoryRename], "Rename the selected category, not combined / channel rows", ), (&[InputCancel, SelectorClose], "INSERT → NORMAL → close"), ], ), ( "CHANNEL TAGS", &[ (&[TagNext, TagPrevious], "Move between tags"), (&[TagEdit], "Enter filter INSERT mode"), (&[TagInputNext, TagInputPrevious], "Move while filtering"), ( &[InputSubmit, InputCancel], "Leave tag filter without saving", ), (&[TagToggle], "Toggle a tag"), (&[TagNew], "Enter a new tag name"), ( &[CategoryRename], "Rename a tag; existing category renames save immediately", ), (&[TagSave], "Save tags"), (&[InputSubmit], "Stage a new tag name"), (&[TagCancel], "Cancel tag changes"), (&[InputCancel], "Abandon a new tag name"), ], ), ( "TEXT INPUT", &[ ( &[InputSubmit], "Submit search / stage tag / save category name", ), ( &[InputCancel], "Leave INSERT / abandon tag or category name", ), (&[DeleteCharacter], "Delete one character"), (&[DeleteWord], "Delete previous word"), (&[DeleteLine], "Clear the line"), ], ), ( "HELP WINDOW", &[ (&[HelpNext, HelpPrevious], "Scroll lines"), (&[HelpPageNext, HelpPagePrevious], "Scroll pages"), (&[Help, HelpClose], "Close help without changing view"), (&[Interrupt], "Quit from any mode or popup"), ], ), ]; let mut text = Text::default(); for &(heading, rows) in sections { text.push_line(""); text.push_line(Line::from(Span::raw(heading).bold())); text.push_line(""); for &(actions, description) in rows { if actions == [InfoOverview, InfoDescription] && self.labels[InfoOverview as usize] != self.labels[InfoDescription as usize] { for (action, description) in [ (InfoOverview, "Open video info"), (InfoDescription, "Open the full description"), ] { text.push_line(format!( "{:<18} {}", self.help_keys(&[action]), description )); } } else { text.push_line(format!("{:<18} {}", self.help_keys(actions), description)); } } } text } fn help_keys(&self, actions: &[Action]) -> String { use Action::*; let matches = |action: Action, expected: &[&str]| { self.labels[action as usize] .iter() .map(String::as_str) .eq(expected.iter().copied()) }; let movement = match actions { [VideoNext, VideoPrevious] | [HelpNext, HelpPrevious] => { matches(actions[0], &["j", "↓", "Ctrl+j"]) && matches(actions[1], &["k", "↑", "Ctrl+k"]) } [SelectorNext, SelectorPrevious] | [TagNext, TagPrevious] => { matches(actions[0], &["j", "↓"]) && matches(actions[1], &["k", "↑"]) } _ => false, }; if movement { return if actions == [HelpNext, HelpPrevious] { "j/k ↑/↓ Ctrl-J/K".into() } else { "j / k or arrows".into() }; } if matches!( actions, [DescriptionNext, DescriptionPrevious] | [SelectorInputNext, SelectorInputPrevious] | [TagInputNext, TagInputPrevious] ) && matches(actions[0], &["↓", "Ctrl+j"]) && matches(actions[1], &["↑", "Ctrl+k"]) { return "↑/↓, Ctrl-J/K".into(); } if actions == [ViewTabNext, ViewTabPrevious] && matches(ViewTabNext, &["Tab", "Ctrl+Tab"]) && matches(ViewTabPrevious, &["Shift+Tab"]) { return "Tab / Shift-Tab".into(); } let mut groups = Vec::new(); for &action in actions { let labels = &self.labels[action as usize]; let group = if labels.is_empty() { "unbound".into() } else { labels.join(", ") }; if !groups.contains(&group) { groups.push(group); } } groups.join(" / ") } } 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 matches!(context, Context::Info | Context::CollectionInfo) { InfoDescription } else { InfoOverview }, ); } if (pair(InfoHide, ViewPrevious) || pair(InfoHide, VideoClear)) && matches!( context, Context::Info | Context::Description | Context::CollectionInfo | Context::CollectionDescription ) { return Some(InfoHide); } if matches!( context, Context::Description | Context::CollectionDescription ) { 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)] #[path = "../tests/unit/keys.rs"] mod tests;