From 66db5176e6187bd259fefebb2290b9ee57f596c7 Mon Sep 17 00:00:00 2001 From: Peter Sauer Date: Sun, 15 Feb 2026 21:24:48 +0100 Subject: [PATCH 1/2] Add --look-ahead flag to limit visible upcoming words Implements a --look-ahead option that shows only the next N upcoming words during the typing test, hiding the rest. This helps users focus on immediate words without being distracted by the full word list. - Add look_ahead: Option to Test struct and CLI - Modify words_to_spans to calculate visible_end based on look_ahead - Add 7 new tests (3 unit + 4 UI) covering visibility limits and clamping Closes #41 Co-Authored-By: Claude Opus 4.6 --- README.md | 1 + src/main.rs | 10 ++++++ src/test/mod.rs | 87 ++++++++++++++++++++++++++++++++++++--------- src/test/results.rs | 27 +++++++------- src/ui.rs | 85 +++++++++++++++++++++++++++++++++++++++++-- 5 files changed, 179 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a5e4313..d57b5c2 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ Options: --no-backspace Disable backspace/delete during test --no-shuffle Don't shuffle word order --no-limit Use entire word list (ignore --words limit) + --look-ahead Show only the next N upcoming words --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 b474971..c213303 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,6 +85,10 @@ struct Opt { #[arg(long)] no_limit: bool, + /// Show only the next N upcoming words + #[arg(long, value_name = "N")] + look_ahead: Option, + /// Show history of past results #[arg(long)] history: bool, @@ -408,6 +412,7 @@ fn main() -> io::Result<()> { opt.sudden_death, opt.case_insensitive, opt.no_backspace, + opt.look_ahead, )); state.render_into(&mut terminal, &config)?; @@ -458,6 +463,7 @@ fn main() -> io::Result<()> { opt.sudden_death, opt.case_insensitive, opt.no_backspace, + opt.look_ahead, )); } _ => continue, @@ -493,6 +499,7 @@ fn main() -> io::Result<()> { opt.sudden_death, opt.case_insensitive, opt.no_backspace, + opt.look_ahead, )); } _ => continue, @@ -518,6 +525,7 @@ fn main() -> io::Result<()> { opt.sudden_death, opt.case_insensitive, opt.no_backspace, + opt.look_ahead, )); } Event::Key(KeyEvent { @@ -535,6 +543,7 @@ fn main() -> io::Result<()> { opt.sudden_death, opt.case_insensitive, opt.no_backspace, + opt.look_ahead, )); } Event::Key(KeyEvent { @@ -558,6 +567,7 @@ fn main() -> io::Result<()> { opt.sudden_death, opt.case_insensitive, opt.no_backspace, + opt.look_ahead, )); } Event::Key(KeyEvent { diff --git a/src/test/mod.rs b/src/test/mod.rs index 6c099b7..df427c5 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -57,6 +57,7 @@ pub struct Test { pub sudden_death_enabled: bool, pub case_insensitive: bool, pub no_backspace: bool, + pub look_ahead: Option, pending_presses: HashMap, } @@ -67,6 +68,7 @@ impl Test { sudden_death_enabled: bool, case_insensitive: bool, no_backspace: bool, + look_ahead: Option, ) -> Self { Self { words: words.into_iter().map(TestWord::from).collect(), @@ -76,6 +78,7 @@ impl Test { sudden_death_enabled, case_insensitive, no_backspace, + look_ahead, pending_presses: HashMap::new(), } } @@ -302,7 +305,7 @@ mod tests { #[test] fn ctrl_h_deletes_single_character() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -321,6 +324,7 @@ mod tests { false, false, false, + None, ); // Complete word 1, move to word 2 type_string(&mut test, "ab"); @@ -343,6 +347,7 @@ mod tests { false, false, false, + None, ); type_string(&mut test, "ab"); test.handle_key(press(KeyCode::Char(' '))); @@ -358,7 +363,7 @@ mod tests { #[test] fn ctrl_letter_is_ignored() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); type_string(&mut test, "he"); assert_eq!(test.words[0].progress, "he"); @@ -379,7 +384,7 @@ mod tests { #[test] fn ctrl_letter_no_event_added() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); type_string(&mut test, "he"); let events_before = test.words[0].events.len(); @@ -393,7 +398,7 @@ mod tests { #[test] fn shift_letter_still_types() { - let mut test = Test::new(vec!["Hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["Hello".to_string()], true, false, false, false, None); let shift_h = KeyEvent { code: KeyCode::Char('H'), @@ -410,7 +415,7 @@ mod tests { #[test] fn ctrl_shift_letter_is_ignored() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); type_string(&mut test, "he"); let ctrl_shift_a = KeyEvent { @@ -434,6 +439,7 @@ mod tests { false, false, false, + None, ); type_string(&mut test, "ab"); assert_eq!(test.current_word, 0); @@ -448,7 +454,7 @@ mod tests { #[test] fn tab_does_not_affect_progress() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); type_string(&mut test, "he"); test.handle_key(press(KeyCode::Tab)); @@ -461,7 +467,7 @@ mod tests { #[test] fn ctrl_w_still_clears_entire_word() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -474,7 +480,7 @@ mod tests { #[test] fn case_insensitive_lowercase_matches_uppercase_word() { - let mut test = Test::new(vec!["Hello".to_string()], true, false, true, false); + let mut test = Test::new(vec!["Hello".to_string()], true, false, true, false, None); type_string(&mut test, "hello"); assert_eq!( test.words[0].progress, "hello", @@ -490,7 +496,7 @@ mod tests { #[test] fn case_insensitive_uppercase_matches_lowercase_word() { - let mut test = Test::new(vec!["hello".to_string()], true, false, true, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, true, false, None); let shift_h = KeyEvent { code: KeyCode::Char('H'), modifiers: KeyModifiers::SHIFT, @@ -507,7 +513,7 @@ mod tests { #[test] fn case_insensitive_correct_flag_on_events() { - let mut test = Test::new(vec!["World".to_string()], true, false, true, false); + let mut test = Test::new(vec!["World".to_string()], true, false, true, false, None); type_string(&mut test, "world"); // All events should be marked correct (case-insensitive comparison) assert!( @@ -518,7 +524,7 @@ mod tests { #[test] fn case_sensitive_uppercase_mismatch() { - let mut test = Test::new(vec!["Hello".to_string()], true, false, false, false); + let mut test = Test::new(vec!["Hello".to_string()], true, false, false, false, None); type_string(&mut test, "hello"); test.handle_key(press(KeyCode::Char(' '))); // In case-sensitive mode, 'hello' != 'Hello', so the word event should be incorrect @@ -532,7 +538,7 @@ mod tests { #[test] fn case_insensitive_auto_complete_last_word() { - let mut test = Test::new(vec!["ABC".to_string()], true, false, true, false); + let mut test = Test::new(vec!["ABC".to_string()], true, false, true, false, None); type_string(&mut test, "abc"); assert!( test.complete, @@ -542,7 +548,7 @@ mod tests { #[test] fn no_backspace_blocks_backspace() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, true); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, true, None); type_string(&mut test, "hel"); assert_eq!(test.words[0].progress, "hel"); @@ -555,7 +561,7 @@ mod tests { #[test] fn no_backspace_blocks_ctrl_h() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, true); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, true, None); type_string(&mut test, "hel"); test.handle_key(press_ctrl(KeyCode::Char('h'))); @@ -567,7 +573,7 @@ mod tests { #[test] fn no_backspace_blocks_ctrl_w() { - let mut test = Test::new(vec!["hello".to_string()], true, false, false, true); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, true, None); type_string(&mut test, "hel"); test.handle_key(press_ctrl(KeyCode::Char('w'))); @@ -579,7 +585,7 @@ mod tests { #[test] fn no_backspace_still_allows_typing() { - let mut test = Test::new(vec!["hi".to_string()], true, false, false, true); + let mut test = Test::new(vec!["hi".to_string()], true, false, false, true, None); type_string(&mut test, "hi"); test.handle_key(press(KeyCode::Char(' '))); assert!( @@ -587,4 +593,53 @@ mod tests { "Normal typing and word completion should still work with no_backspace" ); } + + #[test] + fn look_ahead_stores_value() { + let test = Test::new( + vec!["a".to_string(), "b".to_string(), "c".to_string()], + true, + false, + false, + false, + Some(2), + ); + assert_eq!(test.look_ahead, Some(2)); + } + + #[test] + fn look_ahead_none_by_default() { + let test = Test::new(vec!["a".to_string()], true, false, false, false, None); + assert_eq!(test.look_ahead, None); + } + + #[test] + fn look_ahead_does_not_affect_typing() { + let mut test = Test::new( + vec!["ab".to_string(), "cd".to_string(), "ef".to_string()], + true, + false, + false, + false, + Some(1), + ); + type_string(&mut test, "ab"); + test.handle_key(press(KeyCode::Char(' '))); + assert_eq!( + test.current_word, 1, + "Should advance to next word with look_ahead" + ); + type_string(&mut test, "cd"); + test.handle_key(press(KeyCode::Char(' '))); + assert_eq!( + test.current_word, 2, + "Should advance to third word with look_ahead" + ); + type_string(&mut test, "ef"); + test.handle_key(press(KeyCode::Char(' '))); + assert!( + test.complete, + "Should complete test with look_ahead enabled" + ); + } } diff --git a/src/test/results.rs b/src/test/results.rs index 82adc0d..6a7c360 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, false, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false, false, None); 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, false, false); + let mut test = Test::new(vec!["ab".to_string()], true, false, false, false, None); 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, false, false); + let mut test = Test::new(vec!["aa".to_string()], true, false, false, false, None); 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, false, false); + let mut test = Test::new(vec!["hello".to_string()], true, false, false, false, None); 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, false, false); + let mut test = Test::new(vec!["a".to_string()], true, false, false, false, None); 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)); @@ -382,6 +382,7 @@ mod tests { false, false, false, + None, ); // "fast" — 4 chars in 0.4s = 0.1s/char @@ -426,6 +427,7 @@ mod tests { false, false, false, + None, ); // "correct" — typed correctly @@ -463,6 +465,7 @@ mod tests { false, false, false, + None, ); // "a" — only 1 event (can't measure timing) @@ -486,7 +489,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, false, false); + let mut test = Test::new(words, true, false, false, false, None); for (wi, word) in test.words.iter_mut().enumerate() { for (ci, c) in word.text.clone().chars().enumerate() { @@ -505,7 +508,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, false, false); + let test = Test::new(words.clone(), true, false, false, false, None); let results = Results::from(&test); @@ -522,7 +525,7 @@ mod tests { "apple".to_string(), "mango".to_string(), ]; - let test = Test::new(words.clone(), true, false, false, false); + let test = Test::new(words.clone(), true, false, false, false, None); let results = Results::from(&test); @@ -538,7 +541,7 @@ mod tests { #[test] fn dwell_no_release_events() { - let mut test = Test::new(vec!["abc".to_string()], true, false, false, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false, false, None); 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)); @@ -555,7 +558,7 @@ mod tests { #[test] fn dwell_with_release_events() { let now = Instant::now(); - let mut test = Test::new(vec!["ab".to_string()], true, false, false, false); + let mut test = Test::new(vec!["ab".to_string()], true, false, false, false, None); // 'a' held for 80ms, 'b' held for 120ms test.words[0].events.push(make_dwell_event( @@ -584,7 +587,7 @@ mod tests { #[test] fn dwell_mixed_events() { let now = Instant::now(); - let mut test = Test::new(vec!["abc".to_string()], true, false, false, false); + let mut test = Test::new(vec!["abc".to_string()], true, false, false, false, None); // 'a' has release (100ms), 'b' does not, 'c' has release (50ms) test.words[0].events.push(make_dwell_event( @@ -617,7 +620,7 @@ mod tests { #[test] fn dwell_per_key_averages() { let now = Instant::now(); - let mut test = Test::new(vec!["aa".to_string()], true, false, false, false); + let mut test = Test::new(vec!["aa".to_string()], true, false, false, false, None); // 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 6766cf6..7e9188e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -100,8 +100,13 @@ impl ThemedWidget for &Test { input.render(buf); let target_lines: Vec = { - let words = - words_to_spans(&self.words, self.current_word, theme, self.case_insensitive); + let words = words_to_spans( + &self.words, + self.current_word, + theme, + self.case_insensitive, + self.look_ahead, + ); let mut lines: Vec = Vec::new(); let mut current_line: Vec = Vec::new(); @@ -139,6 +144,7 @@ fn words_to_spans<'a>( current_word: usize, theme: &'a Theme, case_insensitive: bool, + look_ahead: Option, ) -> Vec>> { let mut spans = Vec::new(); @@ -150,7 +156,12 @@ fn words_to_spans<'a>( 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..] { + let visible_end = match look_ahead { + Some(n) => (current_word + 1 + n).min(words.len()), + None => words.len(), + }; + + for word in &words[current_word + 1..visible_end] { let parts = vec![(word.text.clone(), Status::Untyped)]; spans.push(word_parts_to_spans(parts, theme)); } @@ -548,6 +559,74 @@ mod tests { } } + #[test] + fn words_to_spans_no_look_ahead_shows_all() { + let theme = Theme::default(); + let words: Vec = vec!["a", "b", "c", "d", "e"] + .into_iter() + .map(TestWord::from) + .collect(); + let spans = words_to_spans(&words, 0, &theme, false, None); + assert_eq!( + spans.len(), + 5, + "Without look_ahead, all 5 words should be visible" + ); + } + + #[test] + fn words_to_spans_look_ahead_limits_visibility() { + let theme = Theme::default(); + let words: Vec = vec!["a", "b", "c", "d", "e"] + .into_iter() + .map(TestWord::from) + .collect(); + // current_word=0, look_ahead=2: should show word 0 (current) + 2 upcoming = 3 total + let spans = words_to_spans(&words, 0, &theme, false, Some(2)); + assert_eq!( + spans.len(), + 3, + "With look_ahead=2, should show current + 2 upcoming words" + ); + } + + #[test] + fn words_to_spans_look_ahead_one() { + let theme = Theme::default(); + let words: Vec = vec!["a", "b", "c", "d"] + .into_iter() + .map(TestWord::from) + .collect(); + // current_word=1, look_ahead=1: words[0] (typed) + word[1] (current) + word[2] (next) = 3 + let mut word0 = TestWord::from("a"); + word0.progress = "a".to_string(); + let words = vec![ + word0, + TestWord::from("b"), + TestWord::from("c"), + TestWord::from("d"), + ]; + let spans = words_to_spans(&words, 1, &theme, false, Some(1)); + assert_eq!( + spans.len(), + 3, + "With look_ahead=1 at word 1: past(1) + current(1) + upcoming(1) = 3" + ); + } + + #[test] + fn words_to_spans_look_ahead_clamps_to_end() { + let theme = Theme::default(); + let words: Vec = vec!["a", "b"].into_iter().map(TestWord::from).collect(); + // current_word=0, look_ahead=10: only 1 upcoming word exists + let spans = words_to_spans(&words, 0, &theme, false, Some(10)); + assert_eq!( + spans.len(), + 2, + "Look ahead larger than remaining words should clamp to end" + ); + } + #[test] fn current_word_split() { let cases = vec![ From 67646f28e0a343617db1a4f6fc65a03d50999343 Mon Sep 17 00:00:00 2001 From: Peter Sauer Date: Sun, 15 Feb 2026 21:27:47 +0100 Subject: [PATCH 2/2] Address review findings: clarify look-ahead docs, add edge case tests - Clarify help text: past and current word always visible - Add test for look_ahead=0 (shows only current word) - Add test for look_ahead at last word (no upcoming to show) Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- src/main.rs | 2 +- src/ui.rs | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index d57b5c2..8c8294c 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ Options: --no-backspace Disable backspace/delete during test --no-shuffle Don't shuffle word order --no-limit Use entire word list (ignore --words limit) - --look-ahead Show only the next N upcoming words + --look-ahead Show only the next N upcoming words (past and current word always visible) --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 c213303..ffb098f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -85,7 +85,7 @@ struct Opt { #[arg(long)] no_limit: bool, - /// Show only the next N upcoming words + /// Show only the next N upcoming words (past and current word always visible) #[arg(long, value_name = "N")] look_ahead: Option, diff --git a/src/ui.rs b/src/ui.rs index 7e9188e..0e85f07 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -627,6 +627,39 @@ mod tests { ); } + #[test] + fn words_to_spans_look_ahead_zero_shows_only_current() { + let theme = Theme::default(); + let words: Vec = vec!["a", "b", "c", "d"] + .into_iter() + .map(TestWord::from) + .collect(); + // look_ahead=0: show only the current word, no upcoming words + let spans = words_to_spans(&words, 0, &theme, false, Some(0)); + assert_eq!( + spans.len(), + 1, + "With look_ahead=0, only the current word should be visible" + ); + } + + #[test] + fn words_to_spans_look_ahead_at_last_word() { + let theme = Theme::default(); + let mut word0 = TestWord::from("a"); + word0.progress = "a".to_string(); + let mut word1 = TestWord::from("b"); + word1.progress = "b".to_string(); + let words = vec![word0, word1, TestWord::from("c")]; + // current_word=2 (last word), look_ahead=5: no upcoming words to show + let spans = words_to_spans(&words, 2, &theme, false, Some(5)); + assert_eq!( + spans.len(), + 3, + "At last word: past(2) + current(1) + no upcoming = 3" + ); + } + #[test] fn current_word_split() { let cases = vec![