feat: configure keybindings and print default config
This commit is contained in:
+147
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user