diff --git a/README.md b/README.md index 8c8294c..249beeb 100644 --- a/README.md +++ b/README.md @@ -223,8 +223,25 @@ results_chart_y = "gray;italic" # restart/quit prompt in results ui results_restart_prompt = "gray;italic" + +[key_map] +# results screen: quit, new random test, repeat same test +quit = "q" +restart = "r" +repeat = "t" +# results screen: practice missed/slow words +practice_missed = "p" +practice_slow = "s" +# test screen: start new test with different words +new_test = "Tab" ``` +### key binding format + +Key bindings are specified as strings. A single character (e.g. `"q"`) maps to that key. Special keys are capitalized: `Tab`, `Space`, `Enter`, `Esc`, `Backspace`, `Delete`. + +Modifier keys use a prefix with a dash: `C-` for Ctrl, `A-` for Alt. For example, `"C-r"` means Ctrl+R. + ### style format The configuration uses a custom style format which can specify most [ANSI escape styling codes](), encoded as a string. diff --git a/src/config.rs b/src/config.rs index 26cb0f0..8024120 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use crossterm::event::{KeyCode, KeyModifiers}; use ratatui::{ style::{Color, Modifier, Style}, widgets::BorderType, @@ -6,6 +7,7 @@ use serde::{ de::{self, IntoDeserializer}, Deserialize, }; +use std::collections::HashMap; use std::path::PathBuf; #[derive(Debug, Deserialize)] @@ -14,6 +16,7 @@ pub struct Config { pub default_language: String, pub history_file: Option, pub theme: Theme, + pub key_map: KeyMap, } impl Default for Config { @@ -22,10 +25,191 @@ impl Default for Config { default_language: "english200".into(), history_file: None, theme: Theme::default(), + key_map: KeyMap::default(), } } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeyBinding { + pub code: KeyCode, + pub modifiers: KeyModifiers, +} + +impl KeyBinding { + pub fn matches(&self, code: KeyCode, modifiers: KeyModifiers) -> bool { + self.code == code && modifiers == self.modifiers + } +} + +#[derive(Debug, Deserialize)] +#[serde(default)] +pub struct KeyMap { + #[serde(deserialize_with = "deserialize_keybinding")] + pub quit: KeyBinding, + #[serde(deserialize_with = "deserialize_keybinding")] + pub restart: KeyBinding, + #[serde(deserialize_with = "deserialize_keybinding")] + pub repeat: KeyBinding, + #[serde(deserialize_with = "deserialize_keybinding")] + pub practice_missed: KeyBinding, + #[serde(deserialize_with = "deserialize_keybinding")] + pub practice_slow: KeyBinding, + #[serde(deserialize_with = "deserialize_keybinding")] + pub new_test: KeyBinding, +} + +impl Default for KeyMap { + fn default() -> Self { + Self { + quit: KeyBinding { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + }, + restart: KeyBinding { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::NONE, + }, + repeat: KeyBinding { + code: KeyCode::Char('t'), + modifiers: KeyModifiers::NONE, + }, + practice_missed: KeyBinding { + code: KeyCode::Char('p'), + modifiers: KeyModifiers::NONE, + }, + practice_slow: KeyBinding { + code: KeyCode::Char('s'), + modifiers: KeyModifiers::NONE, + }, + new_test: KeyBinding { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + }, + } + } +} + +impl KeyMap { + pub fn check_conflicts(&self) -> Vec { + let bindings: Vec<(&str, &KeyBinding)> = vec![ + ("quit", &self.quit), + ("restart", &self.restart), + ("repeat", &self.repeat), + ("practice_missed", &self.practice_missed), + ("practice_slow", &self.practice_slow), + ("new_test", &self.new_test), + ]; + + let hardcoded_esc = KeyBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + }; + let hardcoded_ctrl_c = KeyBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + }; + + let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new(); + seen.insert((hardcoded_esc.code, hardcoded_esc.modifiers), "Esc (exit)"); + seen.insert( + (hardcoded_ctrl_c.code, hardcoded_ctrl_c.modifiers), + "Ctrl+C (exit)", + ); + + let mut conflicts = Vec::new(); + + for (name, binding) in &bindings { + let key = (binding.code, binding.modifiers); + if let Some(existing) = seen.get(&key) { + conflicts.push(format!( + "Key conflict: '{}' and '{}' are both bound to {}", + existing, + name, + format_keybinding(binding) + )); + } else { + seen.insert(key, name); + } + } + + conflicts + } +} + +pub fn format_keybinding(binding: &KeyBinding) -> String { + let mut parts = Vec::new(); + if binding.modifiers.contains(KeyModifiers::CONTROL) { + parts.push("C".to_string()); + } + if binding.modifiers.contains(KeyModifiers::ALT) { + parts.push("A".to_string()); + } + let key_str = match binding.code { + KeyCode::Char(c) => c.to_string(), + KeyCode::Tab => "Tab".to_string(), + KeyCode::Backspace => "Backspace".to_string(), + KeyCode::Enter => "Enter".to_string(), + KeyCode::Esc => "Esc".to_string(), + KeyCode::Delete => "Delete".to_string(), + _ => format!("{:?}", binding.code), + }; + parts.push(key_str); + parts.join("-") +} + +pub fn parse_keybinding(value: &str) -> Result { + let parts: Vec<&str> = value.split('-').collect(); + match parts.len() { + 1 => { + let code = parse_key_code(parts[0])?; + Ok(KeyBinding { + code, + modifiers: KeyModifiers::NONE, + }) + } + 2 => { + let modifiers = parse_modifier(parts[0])?; + let code = parse_key_code(parts[1])?; + Ok(KeyBinding { code, modifiers }) + } + _ => Err(format!( + "Invalid keybinding '{}': expected 'key' or 'modifier-key'", + value + )), + } +} + +fn parse_modifier(s: &str) -> Result { + match s { + "C" => Ok(KeyModifiers::CONTROL), + "A" => Ok(KeyModifiers::ALT), + _ => Err(format!( + "Unknown modifier '{}': expected 'C' (Ctrl) or 'A' (Alt)", + s + )), + } +} + +fn parse_key_code(s: &str) -> Result { + match s { + "Tab" => Ok(KeyCode::Tab), + "Backspace" => Ok(KeyCode::Backspace), + "Enter" => Ok(KeyCode::Enter), + "Esc" => Ok(KeyCode::Esc), + "Delete" => Ok(KeyCode::Delete), + "Space" => Ok(KeyCode::Char(' ')), + s if s.chars().count() == 1 => { + let c = s.chars().next().unwrap(); + Ok(KeyCode::Char(c)) + } + _ => Err(format!( + "Unknown key '{}': expected a single character or one of Tab, Backspace, Enter, Esc, Delete, Space", + s + )), + } +} + #[derive(Debug, Deserialize)] #[serde(default)] pub struct Theme { @@ -131,6 +315,14 @@ impl Default for Theme { } } +fn deserialize_keybinding<'de, D>(deserializer: D) -> Result +where + D: de::Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_keybinding(&s).map_err(de::Error::custom) +} + fn deserialize_style<'de, D>(deserializer: D) -> Result where D: de::Deserializer<'de>, @@ -372,4 +564,164 @@ mod tests { assert!(config.history_file.is_none()); assert_eq!(config.default_language, "german"); } + + #[test] + fn parse_simple_char_keybinding() { + let kb = parse_keybinding("q").unwrap(); + assert_eq!(kb.code, KeyCode::Char('q')); + assert_eq!(kb.modifiers, KeyModifiers::NONE); + } + + #[test] + fn parse_special_key_keybinding() { + let kb = parse_keybinding("Tab").unwrap(); + assert_eq!(kb.code, KeyCode::Tab); + assert_eq!(kb.modifiers, KeyModifiers::NONE); + + let kb = parse_keybinding("Space").unwrap(); + assert_eq!(kb.code, KeyCode::Char(' ')); + assert_eq!(kb.modifiers, KeyModifiers::NONE); + + let kb = parse_keybinding("Esc").unwrap(); + assert_eq!(kb.code, KeyCode::Esc); + assert_eq!(kb.modifiers, KeyModifiers::NONE); + } + + #[test] + fn parse_ctrl_modifier_keybinding() { + let kb = parse_keybinding("C-r").unwrap(); + assert_eq!(kb.code, KeyCode::Char('r')); + assert_eq!(kb.modifiers, KeyModifiers::CONTROL); + } + + #[test] + fn parse_alt_modifier_keybinding() { + let kb = parse_keybinding("A-q").unwrap(); + assert_eq!(kb.code, KeyCode::Char('q')); + assert_eq!(kb.modifiers, KeyModifiers::ALT); + } + + #[test] + fn parse_unicode_char_keybinding() { + let kb = parse_keybinding("ü").unwrap(); + assert_eq!(kb.code, KeyCode::Char('ü')); + assert_eq!(kb.modifiers, KeyModifiers::NONE); + } + + #[test] + fn parse_invalid_keybinding() { + assert!(parse_keybinding("X-q").is_err()); + assert!(parse_keybinding("a-b-c").is_err()); + assert!(parse_keybinding("InvalidKey").is_err()); + assert!(parse_keybinding("").is_err()); + } + + #[test] + fn keybinding_matches_works() { + let kb = KeyBinding { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + }; + assert!(kb.matches(KeyCode::Char('q'), KeyModifiers::NONE)); + assert!(!kb.matches(KeyCode::Char('r'), KeyModifiers::NONE)); + assert!(!kb.matches(KeyCode::Char('q'), KeyModifiers::CONTROL)); + + let ctrl_r = KeyBinding { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::CONTROL, + }; + assert!(ctrl_r.matches(KeyCode::Char('r'), KeyModifiers::CONTROL)); + assert!(!ctrl_r.matches(KeyCode::Char('r'), KeyModifiers::NONE)); + } + + #[test] + fn keymap_default_values() { + let km = KeyMap::default(); + assert_eq!(km.quit.code, KeyCode::Char('q')); + assert_eq!(km.restart.code, KeyCode::Char('r')); + assert_eq!(km.repeat.code, KeyCode::Char('t')); + assert_eq!(km.practice_missed.code, KeyCode::Char('p')); + assert_eq!(km.practice_slow.code, KeyCode::Char('s')); + assert_eq!(km.new_test.code, KeyCode::Tab); + } + + #[test] + fn keymap_from_toml() { + let toml_str = r#" +[key_map] +quit = "x" +restart = "C-r" +"#; + let config: Config = toml::from_str(toml_str).unwrap(); + assert_eq!(config.key_map.quit.code, KeyCode::Char('x')); + assert_eq!(config.key_map.restart.code, KeyCode::Char('r')); + assert_eq!(config.key_map.restart.modifiers, KeyModifiers::CONTROL); + // unspecified keys keep defaults + assert_eq!(config.key_map.repeat.code, KeyCode::Char('t')); + } + + #[test] + fn keymap_conflict_detection() { + let km = KeyMap::default(); + assert!(km.check_conflicts().is_empty()); + } + + #[test] + fn keymap_conflict_between_actions() { + let mut km = KeyMap::default(); + // create a conflict: quit and restart both bound to 'q' + km.restart = KeyBinding { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + }; + let conflicts = km.check_conflicts(); + assert_eq!(conflicts.len(), 1); + assert!(conflicts[0].contains("quit")); + assert!(conflicts[0].contains("restart")); + } + + #[test] + fn keymap_conflict_with_hardcoded_esc() { + let mut km = KeyMap::default(); + km.quit = KeyBinding { + code: KeyCode::Esc, + modifiers: KeyModifiers::NONE, + }; + let conflicts = km.check_conflicts(); + assert_eq!(conflicts.len(), 1); + assert!(conflicts[0].contains("Esc (exit)")); + } + + #[test] + fn keymap_conflict_with_hardcoded_ctrl_c() { + let mut km = KeyMap::default(); + km.restart = KeyBinding { + code: KeyCode::Char('c'), + modifiers: KeyModifiers::CONTROL, + }; + let conflicts = km.check_conflicts(); + assert_eq!(conflicts.len(), 1); + assert!(conflicts[0].contains("Ctrl+C (exit)")); + } + + #[test] + fn format_keybinding_display() { + let kb = KeyBinding { + code: KeyCode::Char('q'), + modifiers: KeyModifiers::NONE, + }; + assert_eq!(format_keybinding(&kb), "q"); + + let kb = KeyBinding { + code: KeyCode::Char('r'), + modifiers: KeyModifiers::CONTROL, + }; + assert_eq!(format_keybinding(&kb), "C-r"); + + let kb = KeyBinding { + code: KeyCode::Tab, + modifiers: KeyModifiers::NONE, + }; + assert_eq!(format_keybinding(&kb), "Tab"); + } } diff --git a/src/main.rs b/src/main.rs index ffb098f..387dff1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -220,8 +220,15 @@ impl Opt { .unwrap_or_else(|| self.config_dir().join("config.toml")), ) .map(|bytes| { - toml::from_str(str::from_utf8(&bytes).unwrap_or_default()) - .expect("Configuration was ill-formed.") + let s = str::from_utf8(&bytes).unwrap_or_default(); + match toml::from_str(s) { + Ok(config) => config, + Err(e) => { + eprintln!("Error in config.toml: {}", e); + eprintln!("Using default configuration."); + Config::default() + } + } }) .unwrap_or_default() } @@ -286,12 +293,12 @@ impl State { match self { State::Test(test) => { terminal.draw(|f| { - f.render_widget(config.theme.apply_to(test), f.size()); + f.render_widget(config.apply_to(test), f.size()); })?; } State::Results(results) => { terminal.draw(|f| { - f.render_widget(config.theme.apply_to(results), f.size()); + f.render_widget(config.apply_to(results), f.size()); })?; } } @@ -310,6 +317,13 @@ fn main() -> io::Result<()> { dbg!(&config); } + let conflicts = config.key_map.check_conflicts(); + if !conflicts.is_empty() { + for conflict in &conflicts { + eprintln!("Warning: {}", conflict); + } + } + if opt.list_languages { opt.languages() .unwrap() @@ -453,8 +467,10 @@ fn main() -> io::Result<()> { match state { State::Test(ref mut test) => { if let Event::Key(key) = event { - // TAB → restart with new words (no save) - if key.code == KeyCode::Tab && key.kind == KeyEventKind::Press { + // new_test binding (default: TAB) → restart with new words (no save) + if key.kind == KeyEventKind::Press + && config.key_map.new_test.matches(key.code, key.modifiers) + { match opt.gen_contents() { Ok(contents) if !contents.is_empty() => { state = State::Test(Test::new( @@ -485,99 +501,85 @@ fn main() -> io::Result<()> { } } } - State::Results(ref result) => match event { - Event::Key(KeyEvent { - code: KeyCode::Char('r'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => match opt.gen_contents() { - Ok(contents) if !contents.is_empty() => { + State::Results(ref result) => { + if let Event::Key(key) = event { + if key.kind != KeyEventKind::Press { + continue; + } + if config.key_map.restart.matches(key.code, key.modifiers) { + match opt.gen_contents() { + Ok(contents) if !contents.is_empty() => { + state = State::Test(Test::new( + contents, + !opt.no_backtrack, + opt.sudden_death, + opt.case_insensitive, + opt.no_backspace, + opt.look_ahead, + )); + } + _ => continue, + } + } else if config + .key_map + .practice_missed + .matches(key.code, key.modifiers) + { + if result.missed_words.is_empty() { + continue; + } + let mut practice_words: Vec = (result.missed_words) + .iter() + .flat_map(|w| vec![w.clone(); 5]) + .collect(); + practice_words.shuffle(&mut thread_rng()); state = State::Test(Test::new( - contents, + practice_words, !opt.no_backtrack, opt.sudden_death, opt.case_insensitive, opt.no_backspace, opt.look_ahead, )); + } else if config.key_map.repeat.matches(key.code, key.modifiers) { + if result.words.is_empty() { + continue; + } + state = State::Test(Test::new( + result.words.clone(), + !opt.no_backtrack, + opt.sudden_death, + opt.case_insensitive, + opt.no_backspace, + opt.look_ahead, + )); + } else if config + .key_map + .practice_slow + .matches(key.code, key.modifiers) + { + if result.slow_words.is_empty() { + continue; + } + let mut practice_words: Vec = result + .slow_words + .iter() + .flat_map(|w| vec![w.clone(); 5]) + .collect(); + practice_words.shuffle(&mut thread_rng()); + state = State::Test(Test::new( + practice_words, + !opt.no_backtrack, + opt.sudden_death, + opt.case_insensitive, + opt.no_backspace, + opt.look_ahead, + )); + } else if config.key_map.quit.matches(key.code, key.modifiers) { + break; } - _ => continue, - }, - Event::Key(KeyEvent { - code: KeyCode::Char('p'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => { - if result.missed_words.is_empty() { - continue; - } - // repeat each missed word 5 times - let mut practice_words: Vec = (result.missed_words) - .iter() - .flat_map(|w| vec![w.clone(); 5]) - .collect(); - practice_words.shuffle(&mut thread_rng()); - state = State::Test(Test::new( - practice_words, - !opt.no_backtrack, - opt.sudden_death, - opt.case_insensitive, - opt.no_backspace, - opt.look_ahead, - )); - } - Event::Key(KeyEvent { - code: KeyCode::Char('t'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => { - if result.words.is_empty() { - continue; - } - state = State::Test(Test::new( - result.words.clone(), - !opt.no_backtrack, - opt.sudden_death, - opt.case_insensitive, - opt.no_backspace, - opt.look_ahead, - )); - } - Event::Key(KeyEvent { - code: KeyCode::Char('s'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => { - if result.slow_words.is_empty() { - continue; - } - let mut practice_words: Vec = result - .slow_words - .iter() - .flat_map(|w| vec![w.clone(); 5]) - .collect(); - practice_words.shuffle(&mut thread_rng()); - state = State::Test(Test::new( - practice_words, - !opt.no_backtrack, - opt.sudden_death, - opt.case_insensitive, - opt.no_backspace, - opt.look_ahead, - )); } - Event::Key(KeyEvent { - code: KeyCode::Char('q'), - kind: KeyEventKind::Press, - modifiers: KeyModifiers::NONE, - .. - }) => break, - _ => {} - }, + } } state.render_into(&mut terminal, &config)?; diff --git a/src/ui.rs b/src/ui.rs index 0e85f07..2b09227 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,4 +1,4 @@ -use crate::config::Theme; +use crate::config::{format_keybinding, Config, Theme}; use super::test::{results, Test, TestWord}; @@ -53,29 +53,30 @@ impl DrawInner for SizedBlock<'_> { } pub trait ThemedWidget { - fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme); + fn render(self, area: Rect, buf: &mut Buffer, config: &Config); } pub struct Themed<'t, W: ?Sized> { - theme: &'t Theme, + config: &'t Config, widget: W, } impl Widget for Themed<'_, W> { fn render(self, area: Rect, buf: &mut Buffer) { - self.widget.render(area, buf, self.theme) + self.widget.render(area, buf, self.config) } } -impl Theme { +impl Config { pub fn apply_to(&self, widget: W) -> Themed<'_, W> { Themed { - theme: self, + config: self, widget, } } } impl ThemedWidget for &Test { - fn render(self, area: Rect, buf: &mut Buffer, theme: &Theme) { + fn render(self, area: Rect, buf: &mut Buffer, config: &Config) { + let theme = &config.theme; buf.set_style(area, theme.default); // Chunks @@ -298,7 +299,9 @@ fn word_parts_to_spans(parts: Vec<(String, Status)>, theme: &Theme) -> Vec "Press 'q' to quit, 'r' for new test or 't' to repeat", - (false, true) => "Press 'q' to quit, 'r' new, 't' repeat or 's' to practice slow", - (true, false) => "Press 'q' to quit, 'r' new, 't' repeat or 'p' to practice missed", - (false, false) => "Press 'q' quit, 'r' new, 't' repeat, 's' slow or 'p' missed", + (true, true) => format!("Press '{}' quit, '{}' new or '{}' repeat", q, r, t), + (false, true) => { + format!( + "Press '{}' quit, '{}' new, '{}' repeat or '{}' slow", + q, r, t, s + ) + } + (true, false) => { + format!( + "Press '{}' quit, '{}' new, '{}' repeat or '{}' missed", + q, r, t, p + ) + } + (false, false) => { + format!( + "Press '{}' quit, '{}' new, '{}' repeat, '{}' slow or '{}' missed", + q, r, t, s, p + ) + } }; let exit = Span::styled(msg, theme.results_restart_prompt);