985 lines
36 KiB
Rust
985 lines
36 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,
|
|
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<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)>; 9],
|
|
}
|
|
|
|
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)>; 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<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"),
|
|
],
|
|
),
|
|
(
|
|
"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"),
|
|
(&[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()));
|
|
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]
|
|
) && 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.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());
|
|
}
|
|
}
|