From d1fb17cf2d98f679c461a8cef79de393e370ea5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guilherme=20de=20L=C3=A1zari?= Date: Tue, 19 Sep 2023 20:43:14 +0200 Subject: [PATCH] Add "practice missed words" mode (#89) * Add "practice missed words" mode \## Motivation Monkeytype has the possibility of training just the words that you miss and this seems like a straight forward thing to add. \## Implementation Main change is to add a mssed_words item to the Result struct ```rust pub struct Results { pub timing: TimingData, pub accuracy: AccuracyData, pub missed_words: Vec, } ``` And on the Results State, listen for 'p' (practice) to start a new test from missed words. ```rust state = State::Test(Test::from_missed_words(&result.missed_words)); ``` For the `Results` struct I did a very small refactor in the `From<&Test>` Trait implementation. I moved the calculation of each result to its own function, to leave the From function easier to follow. ```rust Self { timing: Self::calc_timing(&events), accuracy: Self::calc_accuracy(&events), missed_words: Self::calc_missed_words(&test), } ``` * Address PR comments --- src/main.rs | 19 +++++- src/test/results.rs | 139 +++++++++++++++++++++++++------------------- src/ui.rs | 11 ++-- 3 files changed, 103 insertions(+), 66 deletions(-) diff --git a/src/main.rs b/src/main.rs index 61ca22b..61c8f61 100644 --- a/src/main.rs +++ b/src/main.rs @@ -249,7 +249,7 @@ fn main() -> crossterm::Result<()> { } } } - State::Results(_) => match event { + State::Results(ref result) => match event { Event::Key(KeyEvent { code: KeyCode::Char('r'), kind: KeyEventKind::Press, @@ -260,6 +260,23 @@ fn main() -> crossterm::Result<()> { "Couldn't get test contents. Make sure the specified language actually exists.", ))); } + 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)); + } Event::Key(KeyEvent { code: KeyCode::Char('q'), kind: KeyEventKind::Press, diff --git a/src/test/results.rs b/src/test/results.rs index 8d7cebd..d7d04ce 100644 --- a/src/test/results.rs +++ b/src/test/results.rs @@ -71,6 +71,7 @@ pub struct AccuracyData { pub struct Results { pub timing: TimingData, pub accuracy: AccuracyData, + pub missed_words: Vec, } impl From<&Test> for Results { @@ -79,67 +80,83 @@ impl From<&Test> for Results { test.words.iter().flat_map(|w| w.events.iter()).collect(); Self { - timing: { - let mut timing = TimingData { - overall_cps: -1.0, - per_event: Vec::new(), - per_key: HashMap::new(), - }; - - // map of keys to a two-tuple (total time, clicks) for counting average - let mut keys: HashMap = HashMap::new(); - - for win in events.windows(2) { - let event_dur = win[1] - .time - .checked_duration_since(win[0].time) - .map(|d| d.as_secs_f64()); - - if let Some(event_dur) = event_dur { - timing.per_event.push(event_dur); - - let key = keys.entry(win[1].key).or_insert((0.0, 0)); - key.0 += event_dur; - key.1 += 1; - } - } - - timing.per_key = keys - .into_iter() - .map(|(key, (total, count))| (key, total / count as f64)) - .collect(); - - timing.overall_cps = - timing.per_event.len() as f64 / timing.per_event.iter().sum::(); - - timing - }, - accuracy: { - let mut acc = AccuracyData { - overall: Fraction::new(0, 0), - per_key: HashMap::new(), - }; - - events - .iter() - .filter(|event| event.correct.is_some()) - .for_each(|event| { - let key = acc - .per_key - .entry(event.key) - .or_insert_with(|| Fraction::new(0, 0)); - - acc.overall.denominator += 1; - key.denominator += 1; - - if event.correct.unwrap() { - acc.overall.numerator += 1; - key.numerator += 1; - } - }); - - acc - }, + timing: calc_timing(&events), + accuracy: calc_accuracy(&events), + missed_words: calc_missed_words(&test), } } } + +fn calc_timing(events: &[&super::TestEvent]) -> TimingData { + let mut timing = TimingData { + overall_cps: -1.0, + per_event: Vec::new(), + per_key: HashMap::new(), + }; + + // map of keys to a two-tuple (total time, clicks) for counting average + let mut keys: HashMap = HashMap::new(); + + for win in events.windows(2) { + let event_dur = win[1] + .time + .checked_duration_since(win[0].time) + .map(|d| d.as_secs_f64()); + + if let Some(event_dur) = event_dur { + timing.per_event.push(event_dur); + + let key = keys.entry(win[1].key).or_insert((0.0, 0)); + key.0 += event_dur; + key.1 += 1; + } + } + + timing.per_key = keys + .into_iter() + .map(|(key, (total, count))| (key, total / count as f64)) + .collect(); + + timing.overall_cps = timing.per_event.len() as f64 / timing.per_event.iter().sum::(); + + timing +} + +fn calc_accuracy(events: &[&super::TestEvent]) -> AccuracyData { + let mut acc = AccuracyData { + overall: Fraction::new(0, 0), + per_key: HashMap::new(), + }; + + events + .iter() + .filter(|event| event.correct.is_some()) + .for_each(|event| { + let key = acc + .per_key + .entry(event.key) + .or_insert_with(|| Fraction::new(0, 0)); + + acc.overall.denominator += 1; + key.denominator += 1; + + if event.correct.unwrap() { + acc.overall.numerator += 1; + key.numerator += 1; + } + }); + + acc +} + +fn calc_missed_words(test: &Test) -> Vec { + let is_missed_word_event = |event: &super::TestEvent| -> bool { + event.correct == Some(false) || event.correct.is_none() + }; + + test.words + .iter() + .filter(|word| word.events.iter().any(is_missed_word_event)) + .map(|word| word.text.clone()) + .collect() +} diff --git a/src/ui.rs b/src/ui.rs index c06f801..3d809f2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -208,10 +208,13 @@ impl ThemedWidget for &results::Results { .constraints([Constraint::Ratio(1, 2), Constraint::Ratio(1, 2)]) .split(res_chunks[0]); - let exit = Span::styled( - "Press 'q' to quit or 'r' for another test.", - theme.results_restart_prompt, - ); + let msg = if self.missed_words.is_empty() { + "Press 'q' to quit or 'r' for another test" + } else { + "Press 'q' to quit, 'r' for another test or 'p' to practice missed words" + }; + + let exit = Span::styled(msg, theme.results_restart_prompt); buf.set_span(chunks[1].x, chunks[1].y, &exit, chunks[1].width); // Sections