diff --git a/README.md b/README.md index 08dad44..9e76ffe 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ Options: --list-languages List installed languages --no-backtrack Disable backtracking to completed words --sudden-death Enable sudden death mode to restart on first error + --case-insensitive Ignore case when comparing typed input --history Show history of past results --last Show only the last N history entries --history-lang Filter history by language diff --git a/src/main.rs b/src/main.rs index 811d7be..14772c8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -69,6 +69,10 @@ struct Opt { #[arg(long)] sudden_death: bool, + /// Ignore case when comparing typed input + #[arg(long)] + case_insensitive: bool, + /// Show history of past results #[arg(long)] history: bool, @@ -378,7 +382,12 @@ fn main() -> io::Result<()> { ); terminal.clear()?; - let mut state = State::Test(Test::new(contents, !opt.no_backtrack, opt.sudden_death)); + let mut state = State::Test(Test::new( + contents, + !opt.no_backtrack, + opt.sudden_death, + opt.case_insensitive, + )); state.render_into(&mut terminal, &config)?; loop { @@ -426,6 +435,7 @@ fn main() -> io::Result<()> { contents, !opt.no_backtrack, opt.sudden_death, + opt.case_insensitive, )); } _ => continue, @@ -455,8 +465,12 @@ fn main() -> io::Result<()> { .. }) => match opt.gen_contents() { Ok(contents) if !contents.is_empty() => { - state = - State::Test(Test::new(contents, !opt.no_backtrack, opt.sudden_death)); + state = State::Test(Test::new( + contents, + !opt.no_backtrack, + opt.sudden_death, + opt.case_insensitive, + )); } _ => continue, }, @@ -479,6 +493,7 @@ fn main() -> io::Result<()> { practice_words, !opt.no_backtrack, opt.sudden_death, + opt.case_insensitive, )); } Event::Key(KeyEvent { @@ -494,6 +509,7 @@ fn main() -> io::Result<()> { result.words.clone(), !opt.no_backtrack, opt.sudden_death, + opt.case_insensitive, )); } Event::Key(KeyEvent { @@ -515,6 +531,7 @@ fn main() -> io::Result<()> { practice_words, !opt.no_backtrack, opt.sudden_death, + opt.case_insensitive, )); } Event::Key(KeyEvent { diff --git a/src/test/mod.rs b/src/test/mod.rs index c2ae6f5..fed423a 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -55,17 +55,24 @@ pub struct Test { pub complete: bool, pub backtracking_enabled: bool, pub sudden_death_enabled: bool, + pub case_insensitive: bool, pending_presses: HashMap, } impl Test { - pub fn new(words: Vec, backtracking_enabled: bool, sudden_death_enabled: bool) -> Self { + pub fn new( + words: Vec, + backtracking_enabled: bool, + sudden_death_enabled: bool, + case_insensitive: bool, + ) -> Self { Self { words: words.into_iter().map(TestWord::from).collect(), current_word: 0, complete: false, backtracking_enabled, sudden_death_enabled, + case_insensitive, pending_presses: HashMap::new(), } } @@ -96,7 +103,11 @@ impl Test { release_time: None, }) } else if !word.progress.is_empty() || word.text.is_empty() { - let correct = word.text == word.progress; + let correct = if self.case_insensitive { + word.text.to_lowercase() == word.progress.to_lowercase() + } else { + word.text == word.progress + }; if self.sudden_death_enabled && !correct { self.reset(); } else { @@ -114,9 +125,17 @@ impl Test { if word.progress.is_empty() && self.backtracking_enabled { self.last_word(); } else { + let is_error = if self.case_insensitive { + !word + .text + .to_lowercase() + .starts_with(&word.progress.to_lowercase()) + } else { + !word.text.starts_with(&word.progress[..]) + }; word.events.push(TestEvent { time: Instant::now(), - correct: Some(!word.text.starts_with(&word.progress[..])), + correct: Some(is_error), key, release_time: None, }); @@ -128,9 +147,17 @@ impl Test { if word.progress.is_empty() && self.backtracking_enabled { self.last_word(); } else { + let is_error = if self.case_insensitive { + !word + .text + .to_lowercase() + .starts_with(&word.progress.to_lowercase()) + } else { + !word.text.starts_with(&word.progress[..]) + }; word.events.push(TestEvent { time: Instant::now(), - correct: Some(!word.text.starts_with(&word.progress[..])), + correct: Some(is_error), key, release_time: None, }); @@ -154,8 +181,19 @@ impl Test { word.progress.clear(); } KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - word.progress.push(c); - let correct = word.text.starts_with(&word.progress[..]); + let ch = if self.case_insensitive { + c.to_lowercase().next().unwrap_or(c) + } else { + c + }; + word.progress.push(ch); + let correct = if self.case_insensitive { + word.text + .to_lowercase() + .starts_with(&word.progress.to_lowercase()) + } else { + word.text.starts_with(&word.progress[..]) + }; if self.sudden_death_enabled && !correct { self.reset(); } else { @@ -165,7 +203,12 @@ impl Test { key, release_time: None, }); - if word.progress == word.text && self.current_word == self.words.len() - 1 { + let words_match = if self.case_insensitive { + word.progress.to_lowercase() == word.text.to_lowercase() + } else { + word.progress == word.text + }; + if words_match && self.current_word == self.words.len() - 1 { self.complete = true; self.current_word = 0; } @@ -252,7 +295,7 @@ mod tests { #[test] fn ctrl_h_deletes_single_character() { - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -269,6 +312,7 @@ mod tests { vec!["ab".to_string(), "cd".to_string()], true, // backtracking enabled false, + false, ); // Complete word 1, move to word 2 type_string(&mut test, "ab"); @@ -289,6 +333,7 @@ mod tests { vec!["ab".to_string(), "cd".to_string()], false, // backtracking disabled false, + false, ); type_string(&mut test, "ab"); test.handle_key(press(KeyCode::Char(' '))); @@ -304,7 +349,7 @@ mod tests { #[test] fn ctrl_letter_is_ignored() { - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); type_string(&mut test, "he"); assert_eq!(test.words[0].progress, "he"); @@ -325,7 +370,7 @@ mod tests { #[test] fn ctrl_letter_no_event_added() { - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); type_string(&mut test, "he"); let events_before = test.words[0].events.len(); @@ -339,7 +384,7 @@ mod tests { #[test] fn shift_letter_still_types() { - let mut test = Test::new(vec!["Hello".to_string()], true, false); + let mut test = Test::new(vec!["Hello".to_string()], true, false, false); let shift_h = KeyEvent { code: KeyCode::Char('H'), @@ -356,7 +401,7 @@ mod tests { #[test] fn ctrl_shift_letter_is_ignored() { - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); type_string(&mut test, "he"); let ctrl_shift_a = KeyEvent { @@ -374,7 +419,7 @@ mod tests { #[test] fn ctrl_space_does_not_advance_word() { - let mut test = Test::new(vec!["ab".to_string(), "cd".to_string()], true, false); + let mut test = Test::new(vec!["ab".to_string(), "cd".to_string()], true, false, false); type_string(&mut test, "ab"); assert_eq!(test.current_word, 0); @@ -388,7 +433,7 @@ mod tests { #[test] fn tab_does_not_affect_progress() { - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); type_string(&mut test, "he"); test.handle_key(press(KeyCode::Tab)); @@ -401,7 +446,7 @@ mod tests { #[test] fn ctrl_w_still_clears_entire_word() { - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -411,4 +456,72 @@ mod tests { "Ctrl+W should clear the entire word progress" ); } + + #[test] + fn case_insensitive_lowercase_matches_uppercase_word() { + let mut test = Test::new(vec!["Hello".to_string()], true, false, true); + type_string(&mut test, "hello"); + assert_eq!( + test.words[0].progress, "hello", + "In case-insensitive mode, typed lowercase should be stored as-is" + ); + // Complete the word + test.handle_key(press(KeyCode::Char(' '))); + assert!( + test.complete, + "Typing 'hello' for 'Hello' should complete in case-insensitive mode" + ); + } + + #[test] + fn case_insensitive_uppercase_matches_lowercase_word() { + let mut test = Test::new(vec!["hello".to_string()], true, false, true); + let shift_h = KeyEvent { + code: KeyCode::Char('H'), + modifiers: KeyModifiers::SHIFT, + kind: KeyEventKind::Press, + state: crossterm::event::KeyEventState::NONE, + }; + test.handle_key(shift_h); + // In case-insensitive mode, uppercase 'H' should be lowercased to 'h' + assert_eq!( + test.words[0].progress, "h", + "In case-insensitive mode, typed uppercase should be stored as lowercase" + ); + } + + #[test] + fn case_insensitive_correct_flag_on_events() { + let mut test = Test::new(vec!["World".to_string()], true, false, true); + type_string(&mut test, "world"); + // All events should be marked correct (case-insensitive comparison) + assert!( + test.words[0].events.iter().all(|e| e.correct == Some(true)), + "All keystrokes should be marked correct in case-insensitive mode" + ); + } + + #[test] + fn case_sensitive_uppercase_mismatch() { + let mut test = Test::new(vec!["Hello".to_string()], true, false, false); + type_string(&mut test, "hello"); + test.handle_key(press(KeyCode::Char(' '))); + // In case-sensitive mode, 'hello' != 'Hello', so the word event should be incorrect + let last_event = test.words[0].events.last().unwrap(); + assert_eq!( + last_event.correct, + Some(false), + "In case-sensitive mode, 'hello' should not match 'Hello'" + ); + } + + #[test] + fn case_insensitive_auto_complete_last_word() { + let mut test = Test::new(vec!["ABC".to_string()], true, false, true); + type_string(&mut test, "abc"); + assert!( + test.complete, + "Typing 'abc' for last word 'ABC' should auto-complete in case-insensitive mode" + ); + } } diff --git a/src/test/results.rs b/src/test/results.rs index e2062e9..69f789d 100644 --- a/src/test/results.rs +++ b/src/test/results.rs @@ -266,7 +266,7 @@ mod tests { #[test] fn non_target_key_excluded_from_per_key() { - let mut test = Test::new(vec!["abc".to_string()], true, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('x', false)); // 'x' not in "abc" test.words[0].events.push(make_event('b', true)); @@ -288,7 +288,7 @@ mod tests { #[test] fn non_target_key_still_counted_in_overall() { - let mut test = Test::new(vec!["ab".to_string()], true, false); + let mut test = Test::new(vec!["ab".to_string()], true, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('x', false)); // wrong key, not in target test.words[0].events.push(make_event('b', true)); @@ -302,7 +302,7 @@ mod tests { #[test] fn target_key_with_errors_tracked_correctly() { - let mut test = Test::new(vec!["aa".to_string()], true, false); + let mut test = Test::new(vec!["aa".to_string()], true, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('a', false)); // 'a' is in target but typed wrong position @@ -316,7 +316,7 @@ mod tests { #[test] fn shift_variant_of_target_key_tracked() { // Target has lowercase 'e', user types uppercase 'E' (Shift mistake) - let mut test = Test::new(vec!["hello".to_string()], true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false); test.words[0].events.push(make_event('h', true)); test.words[0].events.push(make_event('E', false)); // Shift-variant of 'e' test.words[0].events.push(make_event('l', true)); @@ -332,7 +332,7 @@ mod tests { #[test] fn multiple_non_target_keys_all_excluded() { - let mut test = Test::new(vec!["a".to_string()], true, false); + let mut test = Test::new(vec!["a".to_string()], true, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('x', false)); test.words[0].events.push(make_event('y', false)); @@ -380,6 +380,7 @@ mod tests { vec!["fast".to_string(), "slow".to_string(), "mid".to_string()], true, false, + false, ); // "fast" — 4 chars in 0.4s = 0.1s/char @@ -422,6 +423,7 @@ mod tests { vec!["correct".to_string(), "wrong".to_string()], true, false, + false, ); // "correct" — typed correctly @@ -453,7 +455,12 @@ mod tests { #[test] fn slow_words_skips_single_event_words() { let now = Instant::now(); - let mut test = Test::new(vec!["a".to_string(), "hello".to_string()], true, false); + let mut test = Test::new( + vec!["a".to_string(), "hello".to_string()], + true, + false, + false, + ); // "a" — only 1 event (can't measure timing) test.words[0].events.push(make_timed_event('a', true, now)); @@ -476,7 +483,7 @@ mod tests { fn slow_words_caps_at_five() { let now = Instant::now(); let words: Vec = (0..10).map(|i| format!("word{}", i)).collect(); - let mut test = Test::new(words, true, false); + let mut test = Test::new(words, true, false, false); for (wi, word) in test.words.iter_mut().enumerate() { for (ci, c) in word.text.clone().chars().enumerate() { @@ -495,7 +502,7 @@ mod tests { #[test] fn results_preserve_word_list() { let words = vec!["hello".to_string(), "world".to_string(), "test".to_string()]; - let test = Test::new(words.clone(), true, false); + let test = Test::new(words.clone(), true, false, false); let results = Results::from(&test); @@ -512,7 +519,7 @@ mod tests { "apple".to_string(), "mango".to_string(), ]; - let test = Test::new(words.clone(), true, false); + let test = Test::new(words.clone(), true, false, false); let results = Results::from(&test); @@ -528,7 +535,7 @@ mod tests { #[test] fn dwell_no_release_events() { - let mut test = Test::new(vec!["abc".to_string()], true, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false); test.words[0].events.push(make_event('a', true)); test.words[0].events.push(make_event('b', true)); test.words[0].events.push(make_event('c', true)); @@ -545,7 +552,7 @@ mod tests { #[test] fn dwell_with_release_events() { let now = Instant::now(); - let mut test = Test::new(vec!["ab".to_string()], true, false); + let mut test = Test::new(vec!["ab".to_string()], true, false, false); // 'a' held for 80ms, 'b' held for 120ms test.words[0].events.push(make_dwell_event( @@ -574,7 +581,7 @@ mod tests { #[test] fn dwell_mixed_events() { let now = Instant::now(); - let mut test = Test::new(vec!["abc".to_string()], true, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false); // 'a' has release (100ms), 'b' does not, 'c' has release (50ms) test.words[0].events.push(make_dwell_event( @@ -607,7 +614,7 @@ mod tests { #[test] fn dwell_per_key_averages() { let now = Instant::now(); - let mut test = Test::new(vec!["aa".to_string()], true, false); + let mut test = Test::new(vec!["aa".to_string()], true, false, false); // Two presses of 'a': 60ms and 100ms → avg 80ms test.words[0].events.push(make_dwell_event( diff --git a/src/ui.rs b/src/ui.rs index 325482a..6766cf6 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -100,7 +100,8 @@ impl ThemedWidget for &Test { input.render(buf); let target_lines: Vec = { - let words = words_to_spans(&self.words, self.current_word, theme); + let words = + words_to_spans(&self.words, self.current_word, theme, self.case_insensitive); let mut lines: Vec = Vec::new(); let mut current_line: Vec = Vec::new(); @@ -137,15 +138,16 @@ fn words_to_spans<'a>( words: &'a [TestWord], current_word: usize, theme: &'a Theme, + case_insensitive: bool, ) -> Vec>> { let mut spans = Vec::new(); for word in &words[..current_word] { - let parts = split_typed_word(word); + let parts = split_typed_word(word, case_insensitive); spans.push(word_parts_to_spans(parts, theme)); } - let parts_current = split_current_word(&words[current_word]); + let parts_current = split_current_word(&words[current_word], case_insensitive); spans.push(word_parts_to_spans(parts_current, theme)); for word in &words[current_word + 1..] { @@ -167,7 +169,7 @@ enum Status { Overtyped, } -fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { +fn split_current_word(word: &TestWord, case_insensitive: bool) -> Vec<(String, Status)> { let mut parts = Vec::new(); let mut cur_string = String::new(); let mut cur_status = Status::Untyped; @@ -177,10 +179,18 @@ fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { let p = progress.next(); let status = match p { None => Status::CurrentUntyped, - Some(c) => match c { - c if c == tc => Status::CurrentCorrect, - _ => Status::CurrentIncorrect, - }, + Some(c) => { + let matches = if case_insensitive { + c.to_lowercase().eq(tc.to_lowercase()) + } else { + c == tc + }; + if matches { + Status::CurrentCorrect + } else { + Status::CurrentIncorrect + } + } }; if status == cur_status { @@ -210,7 +220,7 @@ fn split_current_word(word: &TestWord) -> Vec<(String, Status)> { parts } -fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { +fn split_typed_word(word: &TestWord, case_insensitive: bool) -> Vec<(String, Status)> { let mut parts = Vec::new(); let mut cur_string = String::new(); let mut cur_status = Status::Untyped; @@ -220,10 +230,18 @@ fn split_typed_word(word: &TestWord) -> Vec<(String, Status)> { let p = progress.next(); let status = match p { None => Status::Untyped, - Some(c) => match c { - c if c == tc => Status::Correct, - _ => Status::Incorrect, - }, + Some(c) => { + let matches = if case_insensitive { + c.to_lowercase().eq(tc.to_lowercase()) + } else { + c == tc + }; + if matches { + Status::Correct + } else { + Status::Incorrect + } + } }; if status == cur_status { @@ -525,7 +543,7 @@ mod tests { for case in cases { let (word, expected) = setup(case); - let got = split_typed_word(&word); + let got = split_typed_word(&word, false); assert_eq!(got, expected); } } @@ -562,7 +580,7 @@ mod tests { for case in cases { let (word, expected) = setup(case); - let got = split_current_word(&word); + let got = split_current_word(&word, false); assert_eq!(got, expected); } }