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
+29 -1
View File
@@ -50,6 +50,8 @@ Each selection opens an independent player instance without replacing existing p
## Controls
These are the default bindings. Overrides in `[keys]` replace an action's bindings; the live hints and `?` reference show the configured keys.
### Normal mode
| Key | Action |
@@ -142,7 +144,7 @@ Favorites = ["@jawed"]
`[categories]` contains **flat, overlapping tags**. A channel can belong to several categories; there is no hierarchy or inheritance. `[channels]` maps readable handles to stable channel URLs or IDs. Saving a channel through the UI records its handle and mapping when metadata is available.
`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, and `[categories]` sections can appear in any order; pin/unpin preserves the existing section order and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted.
`[settings]` holds global options such as `pinned_categories`. The `[settings]`, `[channels]`, `[categories]`, and `[keys]` sections can appear in any order; pin/unpin and tag edits preserve the existing section order, key overrides, and other preferences. For older configs, add `[settings]` immediately above `pinned_categories`. A top-level declaration is no longer accepted.
Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC…` channel IDs. Mapped handles use their stable identity; unmapped handles work directly.
@@ -153,6 +155,32 @@ Category entries accept `@handles`, HTTPS YouTube channel URLs, or canonical `UC
Bulk editing refuses to clear all tags when a target has no `[channels]` registration, preventing accidental unsubscription of category-only channels. If source memberships change while a bulk dialog is open, close and reopen it before saving.
## Configurable keybindings
Print a complete default configuration, including every action and its default bindings:
```sh
tubular --default-config
```
This prints TOML to stdout and exits without reading or writing config, starting the TUI, or needing `yt-dlp`, Terebi, or a terminal. Copy the settings you want into your config; `r` reloads them.
```toml
[keys]
video.info.overview = "I"
video.info.description = "d"
video.info.hide = ["escape", "backspace"]
view.history.previous = ["-", "escape"]
view.history.next = "+"
video.play = ["enter", "ctrl+p"]
```
Each string or array **replaces** that action's default aliases; omitted actions keep their defaults, and `[]` disables an action. Dotted assignments, corresponding subtables such as `[keys.video.info]`, and quoted action names are supported. Unknown actions, invalid chords, duplicate action declarations, and ambiguous bindings in the same context are rejected before saving.
Characters are case-sensitive (`i` differs from `I`). Named keys are case-insensitive: `escape`, `enter`, `tab`, `space`, arrows, `home`, `end`, `pageup`, `pagedown`, `backspace`, `delete`, and `f1`–`f24`. Combine modifiers with `+`, for example `ctrl+j`, `alt+backspace`, `cmd+backspace`, or `shift+tab`. `control`, `option`, and `super` are accepted aliases; `+` itself, or `ctrl++`, can be bound. The terminal must transmit the chord for it to work.
Using the same binding for overview and description preserves the default cycle. Distinct bindings, as above, open either view directly. Preview close takes precedence over history back; description scrolling takes precedence over video selection for shared bindings. View commands do not run inside text inputs or selectors; those have their own actions listed by `--default-config`.
## Loading and previews
Search loads 20 results per page; Feed and category views request 20 uploads per channel per page. Individual channel views request only that channel's uploads, using the same pagination. Moving near the loaded end in normal mode fetches more asynchronously, preserving selection and existing rows. Insert mode and open popups pause automatic prefetch. Deep channel archives can get slower because yt-dlp offsets traverse earlier pages again.
+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());
}
}
+511 -245
View File
File diff suppressed because one or more lines are too long