Files
tubular/src/keys.rs
T

1160 lines
44 KiB
Rust

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<Self, String> {
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::<u8>().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<Vec<String>>,
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<Vec<Key>> {
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<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message.into())
}
impl Bindings {
pub fn from_toml(item: Option<&Item>) -> io::Result<Self> {
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<Key>],
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<Vec<Key>>) -> io::Result<Self> {
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<Action> {
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<Action> {
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<Bindings> {
let document = source.parse::<DocumentMut>().unwrap();
Bindings::from_toml(document.get("keys"))
}
fn press(
bindings: &Bindings,
context: Context,
code: KeyCode,
modifiers: KeyModifiers,
) -> Option<Action> {
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::<String>()
.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());
}
}