From f427dc1f1cffe031f9d72cd08982208937740222 Mon Sep 17 00:00:00 2001 From: Peter Sauer Date: Sun, 15 Feb 2026 21:06:33 +0100 Subject: [PATCH 1/2] Add --case-insensitive flag for case-agnostic typing tests When enabled, typed input is compared case-insensitively against target words. Uppercase characters are stored as lowercase, and word completion checks ignore case differences. Useful for users who want to focus on typing speed without worrying about shift key accuracy. Closes #38 Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + src/main.rs | 23 +++++++-- src/test/mod.rs | 123 +++++++++++++++++++++++++++++++++++++++----- src/test/results.rs | 33 +++++++----- 4 files changed, 151 insertions(+), 29 deletions(-) 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..2c6c982 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 { @@ -154,8 +165,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 +187,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 +279,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 +296,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 +317,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 +333,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 +354,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 +368,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 +385,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 +403,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 +417,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 +430,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 +440,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( From a08ff5c1cd0f1909d2e47c1e3adb465bc7f297bd Mon Sep 17 00:00:00 2001 From: Peter Sauer Date: Sun, 15 Feb 2026 21:10:37 +0100 Subject: [PATCH 2/2] Address review findings: fix backspace correctness and UI rendering - Backspace/Ctrl-H correctness tracking now uses case-insensitive comparison when the flag is enabled, preventing incorrect error marking when progress is lowercase but target has uppercase - UI split functions (split_current_word, split_typed_word) now accept case_insensitive parameter and use char-level lowercase comparison, fixing visual feedback showing correct characters as incorrect Co-Authored-By: Claude Opus 4.6 --- src/test/mod.rs | 20 ++++++++++++++++++-- src/ui.rs | 48 +++++++++++++++++++++++++++++++++--------------- 2 files changed, 51 insertions(+), 17 deletions(-) diff --git a/src/test/mod.rs b/src/test/mod.rs index 2c6c982..fed423a 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -125,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, }); @@ -139,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, }); 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); } }