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();
|
||||
|
||||
Reference in New Issue
Block a user