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();