feat: configure keybindings and print default config

This commit is contained in:
2026-10-11 17:35:58 +02:00
parent eee610105e
commit aaa13ab905
4 changed files with 1506 additions and 249 deletions
+147
View File
@@ -9,11 +9,14 @@ use std::{
use toml_edit::{Array, DocumentMut, Item, Table, value};
use crate::keys;
#[derive(Default, Debug)]
pub struct Config {
pub channels: BTreeMap<String, String>,
pub categories: BTreeMap<String, Vec<String>>,
pub pinned_categories: Vec<String>,
pub keys: keys::Bindings,
}
impl Config {
@@ -70,10 +73,12 @@ pub fn load(path: &Path) -> io::Result<Config> {
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
Ok(Config {
channels,
categories,
pinned_categories,
keys,
})
}
@@ -96,6 +101,7 @@ pub fn set_channel_tags(
// Validate the whole file before changing it or creating directories.
parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
match mappings.get(&handle) {
Some(existing) if existing != &channel => {
return Err(invalid(format!(
@@ -162,6 +168,7 @@ pub fn set_channel_tags(
channels: mappings,
categories,
pinned_categories,
keys,
})
}
@@ -177,6 +184,7 @@ pub fn set_category_tags(
let mappings = parse_channels(&document)?;
let parsed_categories = parse_categories(&document, &mappings)?;
let pinned_categories = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
let targets = parsed_categories
.get(source)
.ok_or_else(|| invalid(format!("Unknown source category: {source:?}")))?;
@@ -272,6 +280,7 @@ pub fn set_category_tags(
channels: mappings,
categories,
pinned_categories,
keys,
})
}
@@ -341,6 +350,7 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
let channels = parse_channels(&document)?;
let categories = parse_categories(&document, &channels)?;
let pins = parse_pins(&document)?;
let keys = keys::Bindings::from_toml(document.get("keys"))?;
if !categories.contains_key(name) {
return Err(invalid(format!("Unknown category: {name:?}")));
}
@@ -370,6 +380,7 @@ pub fn set_category_pinned(path: &Path, name: &str, pinned: bool) -> io::Result<
channels,
categories,
pinned_categories,
keys,
})
}
@@ -652,6 +663,142 @@ mod tests {
}
}
#[test]
fn key_remaps_survive_all_saves_and_section_orders() {
use crate::keys::{Action, Context};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let key_section = "# custom controls\n[keys]\napp.quit = ['x', 'ctrl+x'] # quit note\nview.refresh = []\n\n[keys.selector]\npin = 'f2' # pin note\n";
let sections = [
("keys", key_section),
("settings", "[settings]\npinned_categories = []\n"),
(
"channels",
"[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n",
),
("categories", "[categories]\nA = ['@example']\nB = []\n"),
];
for order in [[0, 1, 2, 3], [3, 1, 0, 2], [2, 3, 1, 0]] {
let original = order
.iter()
.map(|index| sections[*index].1)
.collect::<String>();
fs::write(&path, original).unwrap();
let loaded = load(&path).unwrap();
for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] {
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('x'), modifiers)
),
Some(Action::Quit),
);
}
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)
),
None,
);
assert_eq!(
loaded.keys.resolve(
Context::SelectorNormal,
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
),
Some(Action::SelectorPin),
);
assert_eq!(
loaded.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
let channel_saved =
set_channel_tags(&path, &tags, "@example", "UC4QobU6STFB0P71PMvOGN5A").unwrap();
let tags = BTreeMap::from([("A".to_owned(), false), ("B".to_owned(), true)]);
let category_saved =
set_category_tags(&path, "A", &channel_saved.categories["A"], &tags).unwrap();
let pin_saved = set_category_pinned(&path, "B", true).unwrap();
for config in [
channel_saved,
category_saved,
pin_saved,
load(&path).unwrap(),
] {
assert_eq!(
config.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)
),
Some(Action::Quit)
);
assert_eq!(
config.keys.resolve(
Context::Normal,
KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)
),
None
);
assert_eq!(
config.keys.resolve(
Context::SelectorNormal,
KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)
),
Some(Action::SelectorPin)
);
}
let saved = fs::read_to_string(&path).unwrap();
assert!(
saved.contains(key_section),
"key formatting changed: {saved}"
);
let document = saved.parse::<DocumentMut>().unwrap();
assert_eq!(
document.iter().map(|(name, _)| name).collect::<Vec<_>>(),
order
.iter()
.map(|index| sections[*index].0)
.collect::<Vec<_>>(),
);
}
}
#[test]
fn invalid_key_overrides_refuse_every_save_without_modifying_bytes() {
let directory = TestDirectory::new();
let path = directory.0.join("config.toml");
let tags = BTreeMap::from([("A".to_owned(), true), ("B".to_owned(), true)]);
let expected = vec!["https://www.youtube.com/channel/UC4QobU6STFB0P71PMvOGN5A".to_owned()];
for invalid_keys in [
"[keys]\napp.quit = 42\n",
"[keys]\nunknown.action = 'x'\n",
"[keys]\napp.quit = 'not-a-key'\n",
"[keys]\nview.refresh = 'q'\n",
"keys = 'invalid'\n",
] {
let original = format!(
"{invalid_keys}[settings]\npinned_categories = []\n[channels]\n'@example' = 'UC4QobU6STFB0P71PMvOGN5A'\n[categories]\nA = ['@example']\nB = []\n"
);
fs::write(&path, &original).unwrap();
assert_eq!(load(&path).unwrap_err().kind(), io::ErrorKind::InvalidData);
let channel_error =
set_channel_tags(&path, &tags, "@new", "UCabcdefghijklmnopqrstuv").unwrap_err();
assert_eq!(channel_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let category_error = set_category_tags(&path, "A", &expected, &tags).unwrap_err();
assert_eq!(category_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
let pin_error = set_category_pinned(&path, "B", true).unwrap_err();
assert_eq!(pin_error.kind(), io::ErrorKind::InvalidData);
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
}
}
#[test]
fn settings_can_move_between_sections_and_pin_edits_preserve_their_order() {
let directory = TestDirectory::new();
+816
View File
@@ -0,0 +1,816 @@
use std::{fmt::Write as _, io};
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use toml_edit::{Array, Item};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub enum Context {
Normal,
Info,
Description,
SearchInput,
SelectorNormal,
SelectorInput,
TagList,
TagInput,
Help,
}
const ALL: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::SearchInput,
Context::SelectorNormal,
Context::SelectorInput,
Context::TagList,
Context::TagInput,
Context::Help,
];
const VIEW: &[Context] = &[Context::Normal, Context::Info, Context::Description];
const TEXT: &[Context] = &[
Context::SearchInput,
Context::SelectorInput,
Context::TagInput,
];
const NON_TEXT: &[Context] = &[
Context::Normal,
Context::Info,
Context::Description,
Context::SelectorNormal,
Context::TagList,
Context::Help,
];
struct Definition {
action: Action,
name: &'static str,
description: &'static str,
contexts: &'static [Context],
defaults: &'static [&'static str],
}
// The enum order, storage indices, documentation and default TOML all share this registry.
macro_rules! actions {
($($action:ident, $name:literal, $description:literal, $contexts:expr, [$($key:literal),*];)*) => {
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(usize)]
pub enum Action { $($action),* }
const DEFINITIONS: &[Definition] = &[
$(Definition {
action: Action::$action,
name: $name,
description: $description,
contexts: $contexts,
defaults: &[$($key),*],
}),*
];
};
}
actions! {
Interrupt, "app.interrupt", "Exit immediately", ALL, ["ctrl+c"];
Quit, "app.quit", "Quit the application", &[Context::Normal, Context::Info, Context::Description, Context::Help], ["q"];
Help, "help.toggle", "Open or close keyboard help", NON_TEXT, ["?"];
InputSubmit, "input.submit", "Submit search, selector filter or new tag", TEXT, ["enter"];
InputCancel, "input.cancel", "Leave text input without submitting", TEXT, ["escape"];
DeleteCharacter, "input.delete.character", "Delete the previous character", TEXT, ["backspace"];
DeleteWord, "input.delete.word", "Delete the previous word", TEXT, ["alt+backspace", "ctrl+w"];
DeleteLine, "input.delete.line", "Clear the input line", TEXT, ["super+backspace", "ctrl+u", "alt+super+backspace"];
SearchEdit, "search.edit", "Edit the video search", VIEW, ["i"];
ViewPrevious, "view.history.previous", "Go back in view history", VIEW, ["-", "escape"];
ViewNext, "view.history.next", "Go forward in view history", VIEW, ["+"];
ViewTabNext, "view.tab.next", "Select the next view tab", VIEW, ["tab", "ctrl+tab"];
ViewTabPrevious, "view.tab.previous", "Select the previous view tab", VIEW, ["shift+tab"];
ViewRefresh, "view.refresh", "Refresh the current view and configuration", VIEW, ["r"];
VideoPlay, "video.play", "Play the selected video", VIEW, ["enter"];
VideoNext, "video.select.next", "Select the next video", VIEW, ["j", "down", "ctrl+j"];
VideoPrevious, "video.select.previous", "Select the previous video", VIEW, ["k", "up", "ctrl+k"];
VideoFirst, "video.select.first", "Select the first video", VIEW, ["g", "home"];
VideoLast, "video.select.last", "Select the last video", VIEW, ["G", "end"];
InfoOverview, "video.info.overview", "Show the video overview", VIEW, ["d"];
InfoDescription, "video.info.description", "Show the video description", VIEW, ["d"];
InfoHide, "video.info.hide", "Close the video preview", &[Context::Info, Context::Description], ["escape"];
CategoryBrowse, "category.browse", "Browse categories", VIEW, ["c"];
ChannelBrowse, "channel.browse", "Browse subscribed channels", VIEW, ["u"];
VideoChannel, "video.channel", "Open the selected video's channel", VIEW, ["U"];
ChannelTags, "channel.tags", "Edit the selected channel's category tags", VIEW, ["s"];
CategoryTags, "category.tags", "Edit category tags for all channels in this category", VIEW, ["S"];
SelectorNext, "selector.select.next", "Select the next category or channel", &[Context::SelectorNormal], ["j", "down"];
SelectorPrevious, "selector.select.previous", "Select the previous category or channel", &[Context::SelectorNormal], ["k", "up"];
SelectorInputNext, "selector.input.next", "Select the next match while editing the filter", &[Context::SelectorInput], ["down", "ctrl+j"];
SelectorInputPrevious, "selector.input.previous", "Select the previous match while editing the filter", &[Context::SelectorInput], ["up", "ctrl+k"];
SelectorEdit, "selector.edit", "Edit the category or channel filter", &[Context::SelectorNormal], ["i"];
SelectorOpen, "selector.open", "Open the selected category or channel", &[Context::SelectorNormal], ["enter"];
SelectorPin, "selector.pin", "Toggle category pinning (no effect in channel picker)", &[Context::SelectorNormal], ["p"];
SelectorClose, "selector.close", "Close the category or channel picker", &[Context::SelectorNormal], ["escape"];
TagNext, "tags.select.next", "Select the next category tag", &[Context::TagList], ["j", "down"];
TagPrevious, "tags.select.previous", "Select the previous category tag", &[Context::TagList], ["k", "up"];
TagToggle, "tags.toggle", "Toggle the selected category tag", &[Context::TagList], ["space"];
TagNew, "tags.new", "Enter a new category tag name", &[Context::TagList], ["n"];
TagSave, "tags.save", "Save category tag changes", &[Context::TagList], ["enter"];
TagCancel, "tags.cancel", "Discard category tag changes and close", &[Context::TagList], ["escape"];
DescriptionNext, "video.description.scroll.next", "Scroll the description down one line", &[Context::Description], ["down", "ctrl+j"];
DescriptionPrevious, "video.description.scroll.previous", "Scroll the description up one line", &[Context::Description], ["up", "ctrl+k"];
DescriptionPageNext, "video.description.page.next", "Scroll the description down one page", &[Context::Description], ["pagedown"];
DescriptionPagePrevious, "video.description.page.previous", "Scroll the description up one page", &[Context::Description], ["pageup"];
HelpNext, "help.scroll.next", "Scroll help down one line", &[Context::Help], ["j", "down", "ctrl+j"];
HelpPrevious, "help.scroll.previous", "Scroll help up one line", &[Context::Help], ["k", "up", "ctrl+k"];
HelpPageNext, "help.page.next", "Scroll help down one page", &[Context::Help], ["pagedown"];
HelpPagePrevious, "help.page.previous", "Scroll help up one page", &[Context::Help], ["pageup"];
HelpClose, "help.close", "Close keyboard help", &[Context::Help], ["escape"];
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Key {
code: KeyCode,
modifiers: KeyModifiers,
}
impl Key {
fn normalized(mut self) -> Self {
if self.code == KeyCode::BackTab {
self.code = KeyCode::Tab;
self.modifiers.insert(KeyModifiers::SHIFT);
}
if let KeyCode::Char(character) = self.code
&& !character.is_control()
&& self.modifiers.contains(KeyModifiers::SHIFT)
{
// Terminals usually report the already-shifted character. A single-character
// uppercase mapping also accepts synthetic shift+i as I without allocating.
let mut uppercase = character.to_uppercase();
if let Some(first) = uppercase.next()
&& uppercase.next().is_none()
{
self.code = KeyCode::Char(first);
}
self.modifiers.remove(KeyModifiers::SHIFT);
}
self
}
fn parse(description: &str) -> Result<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) -> String {
let mut text = String::from("Keyboard bindings\n\n");
for definition in DEFINITIONS {
let aliases = &self.labels[definition.action as usize];
let bindings = if aliases.is_empty() {
"unbound".to_owned()
} else {
aliases.join(", ")
};
let _ = write!(
text,
"{}: {} — {} [",
definition.name, bindings, definition.description
);
for (index, context) in definition.contexts.iter().enumerate() {
if index != 0 {
text.push_str(", ");
}
text.push_str(match context {
Context::Normal => "video list",
Context::Info => "video overview",
Context::Description => "video description",
Context::SearchInput => "search input",
Context::SelectorNormal => "category/channel picker",
Context::SelectorInput => "picker filter input",
Context::TagList => "category tag picker",
Context::TagInput => "new tag input",
Context::Help => "help",
});
}
text.push_str("]\n");
}
text.push_str("\nText input inserts literal characters; normal-mode commands do not run there.\n\
Shared overview/description bindings cycle overview → description → overview; distinct bindings open either view directly.\n\
Preview close takes priority over history back. Description scrolling takes priority over video selection on shared bindings.\n");
text
}
}
fn overlap_winner(context: Context, first: Action, second: Action) -> Option<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 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());
}
}
+514 -248
View File
File diff suppressed because one or more lines are too long