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, SearchInput, SelectorNormal, SelectorInput, TagList, TagFilter, TagInput, Help, } const ALL: &[Context] = &[ Context::Normal, Context::Info, Context::Description, Context::SearchInput, Context::SelectorNormal, Context::SelectorInput, Context::TagList, Context::TagFilter, Context::TagInput, Context::Help, ]; const VIEW: &[Context] = &[Context::Normal, Context::Info, Context::Description]; const TEXT: &[Context] = &[ Context::SearchInput, Context::SelectorInput, Context::TagFilter, 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, browse selector, leave tag filter or stage 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"]; WatchLaterSave, "video.watch_later.save", "Save the selected video to watch later", VIEW, ["w"]; WatchLaterBrowse, "view.watch_later.browse", "Browse this category's watch later, or the combined collection elsewhere", &[Context::Normal], ["W"]; WatchLaterFilter, "view.watch_later.filter", "Cycle watch-later filter: all, unwatched, watched", &[Context::Normal], ["f"]; WatchLaterRemove, "view.watch_later.remove", "Confirm removal of the current filtered watch-later subset", &[Context::Normal], ["D"]; SavedVideoSave, "video.saved.save", "Save the selected video (also in previews)", VIEW, ["v"]; SavedVideoBrowse, "view.saved.browse", "Browse this category's saved videos, or the combined collection elsewhere", &[Context::Normal], ["V"]; SavedVideoRemove, "video.saved.remove", "Remove the selected saved video (also in previews)", VIEW, ["x"]; 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"]; 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], ["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)>; 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 the selected video in Terebi"), (&[SearchEdit], "Edit the search query"), ( &[InfoOverview, InfoDescription], "Info → full description → info", ), (&[VideoChannel], "Browse the selected video's channel"), (&[ChannelBrowse], "Choose a saved channel"), (&[CategoryBrowse], "Choose a saved category"), (&[ChannelTags], "Edit the selected channel's tags"), (&[CategoryTags], "Edit all channels in a 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"), (&[Help], "Open / close this reference"), (&[Quit, Interrupt], "Quit"), ], ), ( "WATCH LATER", &[ (&[WatchLaterSave], "Save the selected video"), ( &[WatchLaterBrowse], "Browse category watch later; combined elsewhere", ), (&[WatchLaterFilter], "Cycle all / unwatched / watched"), ( &[WatchLaterRemove], "Confirm removal of filtered subset (combined: all matches)", ), ], ), ( "SAVED VIDEOS", &[ ( &[SavedVideoSave], "Save the selected video (also in previews)", ), ( &[SavedVideoBrowse], "Browse category saved videos; combined elsewhere", ), ( &[SavedVideoRemove], "Remove the selected video (also in previews)", ), ], ), ( "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 SELECTOR", &[ (&[SelectorNext, SelectorPrevious], "Move in NORMAL mode"), (&[SelectorEdit], "Enter filter INSERT mode"), ( &[SelectorInputNext, SelectorInputPrevious], "Move while filtering", ), (&[SelectorOpen, InputSubmit], "Browse the selected match"), (&[SelectorPin], "Pin / unpin a category (NORMAL)"), (&[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"), (&[TagSave], "Save tags"), (&[InputSubmit], "Stage a new tag name"), (&[TagCancel], "Cancel tag changes"), (&[InputCancel], "Abandon a new tag name"), ], ), ( "TEXT INPUT", &[ (&[InputSubmit], "Submit; search returns to NORMAL"), (&[InputCancel], "Leave INSERT / abandon tag 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 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 only_section_headers_are_bold_in_the_grouped_overview() { use ratatui::{ buffer::Buffer, layout::Rect, style::Modifier, widgets::{Paragraph, Widget, Wrap}, }; let bindings = configured("[keys]\nvideo.play = 'f24'\n").unwrap(); for width in [21, 64] { let area = Rect::new(0, 0, width, 16); let mut buffer = Buffer::empty(area); Paragraph::new(bindings.help_text()) .wrap(Wrap { trim: false }) .render(area, &mut buffer); assert!(buffer[(0, 1)].modifier.contains(Modifier::BOLD)); for y in 2..area.height { for x in 0..width { assert!(!buffer[(x, y)].modifier.contains(Modifier::BOLD)); } } let binding_visible = (2..area.height).any(|y| { (0..width) .map(|x| buffer[(x, y)].symbol()) .collect::() .contains("F24") }); assert!( binding_visible, "custom shortcut must remain visible after wrapping" ); } } #[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.video.saved]\nremove = []\n[keys.view]\nrefresh = ['x', 'CTRL+y']\n[keys.app]\nquit = []\n[keys.input]\nsubmit = 'RETURN'\ncancel = 'EsC'\n").unwrap(); let quoted = configured("[keys]\n'video.saved.remove' = []\n'view.refresh' = ['x', 'CTRL+y']\n'app.quit' = []\n'input.submit' = 'RETURN'\n'input.cancel' = 'EsC'\n").unwrap(); 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 watch_later_overrides_disable_and_respect_contexts() { let defaults = Bindings::default(); for (key, action) in [ ('w', Action::WatchLaterBrowse), ('d', Action::WatchLaterRemove), ] { assert_eq!( press( &defaults, Context::Normal, KeyCode::Char(key), KeyModifiers::SHIFT ), Some(action), ); } assert_eq!( press( &defaults, Context::Info, KeyCode::Char('w'), KeyModifiers::NONE ), Some(Action::WatchLaterSave), ); assert_eq!( press( &defaults, Context::Info, KeyCode::Char('d'), KeyModifiers::NONE ), Some(Action::InfoDescription), ); let bindings = configured( "[keys.video.watch_later]\nsave = 'alt+w'\n[keys.view.watch_later]\nbrowse = 'shift+x'\nfilter = 'ctrl+f'\nremove = []\n", ) .unwrap(); for context in ALL { assert_eq!( press(&bindings, *context, KeyCode::Char('w'), KeyModifiers::ALT), VIEW.contains(context).then_some(Action::WatchLaterSave), ); for (code, modifiers, action) in [ ( KeyCode::Char('x'), KeyModifiers::SHIFT, Action::WatchLaterBrowse, ), ( KeyCode::Char('f'), KeyModifiers::CONTROL, Action::WatchLaterFilter, ), ] { assert_eq!( press(&bindings, *context, code, modifiers), (*context == Context::Normal).then_some(action), ); } for key in ['w', 'W', 'f', 'D'] { assert_eq!( press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE), None, ); } } let help = bindings.help_text().to_string(); assert!(help.contains("Alt+w") && help.contains("X") && help.contains("Ctrl+f")); assert!(help.contains("unbound") && help.contains("WATCH LATER")); let collision = configured("[keys]\nview.watch_later.browse = 'w'") .unwrap_err() .to_string(); assert!( collision.contains("view.watch_later.browse") && collision.contains("video.watch_later.save") ); } #[test] fn saved_video_overrides_disable_and_respect_contexts() { let defaults = Bindings::default(); for context in ALL { for (key, action, enabled) in [ ('v', Action::SavedVideoSave, VIEW.contains(context)), ('V', Action::SavedVideoBrowse, *context == Context::Normal), ('x', Action::SavedVideoRemove, VIEW.contains(context)), ] { assert_eq!( press(&defaults, *context, KeyCode::Char(key), KeyModifiers::NONE), enabled.then_some(action), ); } } let bindings = configured( "[keys.video.saved]\nsave = 'alt+v'\nremove = []\n[keys.view.saved]\nbrowse = 'shift+x'\n", ) .unwrap(); for context in ALL { assert_eq!( press(&bindings, *context, KeyCode::Char('v'), KeyModifiers::ALT), VIEW.contains(context).then_some(Action::SavedVideoSave), ); assert_eq!( press(&bindings, *context, KeyCode::Char('x'), KeyModifiers::SHIFT), (*context == Context::Normal).then_some(Action::SavedVideoBrowse), ); for key in ['v', 'V', 'x'] { assert_eq!( press(&bindings, *context, KeyCode::Char(key), KeyModifiers::NONE), None, ); } } let collision = configured("[keys]\nview.saved.browse = 'v'") .unwrap_err() .to_string(); assert!(collision.contains("view.saved.browse") && collision.contains("video.saved.save")); } #[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()); } }