diff --git a/cspell.json b/cspell.json index ca42b52..dbdcf62 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"version":"0.2","language":"en","flagWords":[],"words":["crossterm","ratatui","donkeytype","tempfile","mockall","automock","foobarbaaz","foobarbazqux","withf","rngs","Szewnia","chrono","datetime","Datelike"]} +{"version":"0.2","flagWords":[],"words":["crossterm","ratatui","donkeytype","tempfile","mockall","automock","foobarbaaz","foobarbazqux","withf","rngs","Szewnia","chrono","datetime","Datelike","Timelike"],"language":"en"} diff --git a/src/expected_input.rs b/src/expected_input.rs index 7cd7d13..c7d0c86 100644 --- a/src/expected_input.rs +++ b/src/expected_input.rs @@ -7,7 +7,7 @@ //! //! Dictionary file should be a text file in format of single words per line. -use anyhow::Context; +use anyhow::{Context, Result}; use mockall::automock; use rand::{seq::SliceRandom, thread_rng, Rng}; use std::io::Read; @@ -50,7 +50,8 @@ impl ExpectedInput { } if config.uppercase == true { - create_uppercase_words(&mut string_vec, words_start_pos, config.uppercase_ratio); + create_uppercase_words(&mut string_vec, words_start_pos, config.uppercase_ratio) + .context("Unable to create uppercase words")?; str_vec = string_vec.iter().map(|s| s.as_str()).collect(); } @@ -91,16 +92,25 @@ fn replace_words_with_numbers( return change_to_num_threshold - 1; } -fn create_uppercase_words(string_vec: &mut Vec, pos: usize, uppercase_ratio: f64) { +fn create_uppercase_words( + string_vec: &mut Vec, + pos: usize, + uppercase_ratio: f64, +) -> Result<()> { let num_uppercase_words = (uppercase_ratio * string_vec[pos..].len() as f64).round() as usize; for i in pos..pos + num_uppercase_words { if string_vec[i] != "" { let mut v: Vec = string_vec[i].chars().collect(); - v[0] = v[0].to_uppercase().nth(0).unwrap(); + v[0] = v[0] + .to_uppercase() + .nth(0) + .context("Unable to get first character of a word")?; let s: String = v.into_iter().collect(); string_vec[i] = s; } } + + Ok(()) } /// extracted to trait to create mock with `mockall` crate diff --git a/src/main.rs b/src/main.rs index e086822..c81b867 100644 --- a/src/main.rs +++ b/src/main.rs @@ -116,7 +116,7 @@ fn main() -> anyhow::Result<()> { match res { Ok(test_results) => { if test_results.completed { - if let Err(err) = test_results.render_chart(&mut terminal) { + if let Err(err) = test_results.render_results(&mut terminal) { eprintln!("{:?}", err); restore_terminal(terminal).context("Unable to restore terminal")?; diff --git a/src/test_results.rs b/src/test_results.rs index 7b9eee9..cdad793 100644 --- a/src/test_results.rs +++ b/src/test_results.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use chrono::{DateTime, Datelike, Local, Timelike}; use crossterm::event::{self, Event, KeyCode}; use ratatui::{ - prelude::{Backend, Constraint, Direction, Layout}, + prelude::{Backend, Constraint, Direction, Layout, Rect}, style::{Style, Stylize}, widgets::{Bar, BarGroup, Block}, widgets::{BarChart, Paragraph}, @@ -15,9 +15,12 @@ use ratatui::{ }; use serde::{Deserialize, Serialize}; -use std::{fs::create_dir_all, thread::sleep, time::Duration}; +use std::{fs::create_dir_all, path::PathBuf, rc::Rc, thread::sleep, time::Duration}; -use crate::config::Config; +use crate::{ + config::Config, + runner::{FrameWrapper, FrameWrapperInterface}, +}; /// TestResults struct is combining test statistics with configuration of the test. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -112,26 +115,12 @@ impl TestResults { /// saves test statistics and configuration to a file in users home directory pub fn save_to_file(&self) -> Result<(), anyhow::Error> { - let results_dir_path = dirs::home_dir() - .context("Unable to get home directory")? - .join(".local") - .join("share") - .join("donkeytype"); - - if !results_dir_path.exists() { - create_dir_all(results_dir_path.clone()) - .context("Unable to create results directory for results file")?; - } - - let results_file_path = results_dir_path.join("donkeytype-results.csv"); + create_results_dir_if_not_exist() + .context("Unable to ensure that results directory exist")?; + let results_file_path = + get_results_file_path().context("Unable to ge results file path")?; - let mut reader = csv::Reader::from_path(results_file_path.clone()) - .context("Unable to create CSV Reader")?; - - let records: Vec = reader - .deserialize() - .collect::>() - .context("Unable to deserialize results")?; + let records = read_previous_results().context("Unable to read previous results")?; let mut writer = csv::Writer::from_path(results_file_path).context("Unable to create CSV Writer")?; @@ -153,34 +142,12 @@ impl TestResults { Ok(()) } - pub fn render_chart(&self, terminal: &mut Terminal) -> Result<()> { - let results_dir_path = dirs::home_dir() - .context("Unable to get home directory")? - .join(".local") - .join("share") - .join("donkeytype"); - - if !results_dir_path.exists() { - create_dir_all(results_dir_path.clone()) - .context("Unable to create results directory for results file")?; - } - - let results_file_path = results_dir_path.join("donkeytype-results.csv"); - - let mut reader = csv::Reader::from_path(results_file_path.clone()) - .context("Unable to create CSV Reader")?; - - let mut records: Vec = reader - .deserialize() - .collect::>() - .context("Unable to deserialize results")?; - + pub fn render_results(&self, terminal: &mut Terminal) -> Result<()> { + let mut records = read_previous_results().context("Unable to read previous results")?; records.push(self.clone()); loop { terminal.draw(|frame| { - let mut records_to_show = records.clone(); - let areas = Layout::default() .direction(Direction::Vertical) .constraints( @@ -199,149 +166,22 @@ impl TestResults { Constraint::Length(1), Constraint::Length(1), Constraint::Min(1), - Constraint::Length(1), ] .as_ref(), ) .split(frame.size()); - frame.render_widget(Paragraph::new("Test completed"), areas[0]); - if let Some(wpm) = self.wpm { - frame.render_widget(Paragraph::new(format!("WPM: {:.2}", wpm)), areas[1]); - } - if let Some(raw_accuracy) = self.raw_accuracy { - frame.render_widget( - Paragraph::new(format!("Raw accuracy: {:.2}%", raw_accuracy)), - areas[2], - ); - } - if let Some(raw_valid_characters_count) = self.raw_valid_characters_count { - frame.render_widget( - Paragraph::new(format!( - "Raw valid characters: {}", - raw_valid_characters_count - )), - areas[3], - ); - } - if let Some(raw_mistakes_count) = self.raw_mistakes_count { - frame.render_widget( - Paragraph::new(format!("Raw mistakes: {}", raw_mistakes_count)), - areas[4], - ); - } - if let Some(raw_typed_characters_count) = self.raw_typed_characters_count { - frame.render_widget( - Paragraph::new(format!( - "Raw characters typed: {}", - raw_typed_characters_count - )), - areas[5], - ); - } - if let Some(accuracy) = self.accuracy { - frame.render_widget( - Paragraph::new(format!("Accuracy after corrections: {:.2}%", accuracy)), - areas[6], - ); - } - if let Some(valid_characters_count) = self.valid_characters_count { - frame.render_widget( - Paragraph::new(format!( - "Valid characters after corrections: {}", - valid_characters_count - )), - areas[7], - ); - } - if let Some(mistakes_count) = self.mistakes_count { - frame.render_widget( - Paragraph::new(format!("Mistakes after corrections: {}", mistakes_count)), - areas[8], - ); - } - - if let Some(typed_characters_count) = self.typed_characters_count { - frame.render_widget( - Paragraph::new(format!( - "Characters typed after corrections: {}", - typed_characters_count, - )), - areas[9], - ); - } - - let bar_width = 5; - let frame_width = frame.size().width; - let bars_to_show = ((frame_width + 1) / (bar_width + 1)) as usize; - - if records.len() >= bars_to_show { - records_to_show = records[records.len() - bars_to_show..].to_vec(); - } + frame.render_widget(Paragraph::new("Test completed"), areas[0]); frame.render_widget( - BarChart::default() - .block(Block::default().title("Previous chart")) - .bar_width(bar_width) - .bar_gap(1) - .bar_style(Style::new().white().on_black()) - .value_style(Style::new().black().on_white()) - .label_style(Style::new().yellow()) - .data( - BarGroup::default().bars( - &records_to_show - .iter() - .map(|r| { - Bar::default().value(if let Some(wpm) = r.wpm { - wpm as u64 - } else { - 0 - }) - }) - .collect::>(), - ), - ), - areas[10], - ); - frame.render_widget( - Paragraph::new( - records_to_show - .iter() - .map(|r| { - format!( - "{}:{} ", - fmt_num(r.local_datetime.hour()), - fmt_num(r.local_datetime.minute()) - ) - }) - .collect::(), - ), - areas[11], - ); - frame.render_widget( - Paragraph::new( - records_to_show - .iter() - .map(|r| { - format!( - "{}/{} ", - fmt_num(r.local_datetime.month()), - fmt_num(r.local_datetime.day()) - ) - }) - .collect::(), - ), - areas[12], - ); - frame.render_widget( - Paragraph::new( - records_to_show - .iter() - .map(|r| format!("{} ", r.local_datetime.year())) - .collect::(), - ), - areas[13], + Paragraph::new("Press to quit") + .alignment(ratatui::prelude::Alignment::Right) + .yellow(), + areas[0], ); - frame.render_widget(Paragraph::new("Press to quit"), areas[14]); + + let mut frame_wrapper = FrameWrapper::new(frame); + self.render_stats(&mut frame_wrapper, &areas); + self.render_chart(&mut frame_wrapper, &areas, &records); })?; if event::poll(Duration::from_millis(100)).context("Unable to poll for event")? { @@ -359,6 +199,194 @@ impl TestResults { Ok(()) } + + fn render_chart( + &self, + frame: &mut impl FrameWrapperInterface, + areas: &Rc<[Rect]>, + records: &Vec, + ) { + let mut records_to_show = records.clone(); + let bar_width = 5; + let frame_width = frame.size().width; + let bars_to_show = ((frame_width + 1) / (bar_width + 1)) as usize; + if records.len() >= bars_to_show { + records_to_show = records[records.len() - bars_to_show..].to_vec(); + } + + frame.render_widget( + BarChart::default() + .block(Block::default().title("Previous results:")) + .bar_width(bar_width) + .bar_gap(1) + .bar_style(Style::new().white().on_black()) + .value_style(Style::new().black().on_white()) + .data( + BarGroup::default().bars( + &records_to_show + .iter() + .map(|r| { + Bar::default().value(if let Some(wpm) = r.wpm { + wpm as u64 + } else { + 0 + }) + }) + .collect::>(), + ), + ), + areas[10], + ); + frame.render_widget( + Paragraph::new( + records_to_show + .iter() + .map(|r| { + format!( + "{}:{} ", + fmt_num(r.local_datetime.hour()), + fmt_num(r.local_datetime.minute()) + ) + }) + .collect::(), + ), + areas[11], + ); + frame.render_widget( + Paragraph::new( + records_to_show + .iter() + .map(|r| { + format!( + "{}/{} ", + fmt_num(r.local_datetime.month()), + fmt_num(r.local_datetime.day()) + ) + }) + .collect::(), + ), + areas[12], + ); + frame.render_widget( + Paragraph::new( + records_to_show + .iter() + .map(|r| format!("{} ", r.local_datetime.year())) + .collect::(), + ), + areas[13], + ); + } + + fn render_stats(&self, frame: &mut impl FrameWrapperInterface, areas: &Rc<[Rect]>) { + if let Some(wpm) = self.wpm { + frame.render_widget(Paragraph::new(format!("WPM: {:.2}", wpm)), areas[1]); + } + if let Some(raw_accuracy) = self.raw_accuracy { + frame.render_widget( + Paragraph::new(format!("Raw accuracy: {:.2}%", raw_accuracy)), + areas[2], + ); + } + if let Some(raw_valid_characters_count) = self.raw_valid_characters_count { + frame.render_widget( + Paragraph::new(format!( + "Raw valid characters: {}", + raw_valid_characters_count + )), + areas[3], + ); + } + if let Some(raw_mistakes_count) = self.raw_mistakes_count { + frame.render_widget( + Paragraph::new(format!("Raw mistakes: {}", raw_mistakes_count)), + areas[4], + ); + } + if let Some(raw_typed_characters_count) = self.raw_typed_characters_count { + frame.render_widget( + Paragraph::new(format!( + "Raw characters typed: {}", + raw_typed_characters_count + )), + areas[5], + ); + } + if let Some(accuracy) = self.accuracy { + frame.render_widget( + Paragraph::new(format!("Accuracy after corrections: {:.2}%", accuracy)), + areas[6], + ); + } + if let Some(valid_characters_count) = self.valid_characters_count { + frame.render_widget( + Paragraph::new(format!( + "Valid characters after corrections: {}", + valid_characters_count + )), + areas[7], + ); + } + if let Some(mistakes_count) = self.mistakes_count { + frame.render_widget( + Paragraph::new(format!("Mistakes after corrections: {}", mistakes_count)), + areas[8], + ); + } + + if let Some(typed_characters_count) = self.typed_characters_count { + frame.render_widget( + Paragraph::new(format!( + "Characters typed after corrections: {}", + typed_characters_count, + )), + areas[9], + ); + } + } +} + +fn get_results_dir_path() -> Result { + let dir_path = dirs::home_dir() + .context("Unable to get home directory")? + .join(".local") + .join("share") + .join("donkeytype"); + + Ok(dir_path) +} + +fn get_results_file_path() -> Result { + let dir_path = get_results_dir_path().context("Unable to get results directory path")?; + let file_path = dir_path.join("donkeytype-results.csv"); + + Ok(file_path) +} + +fn create_results_dir_if_not_exist() -> Result<()> { + let results_dir_path = + get_results_dir_path().context("Unable to get results directory path")?; + + if !results_dir_path.exists() { + create_dir_all(results_dir_path.clone()) + .context("Unable to create results directory for results file")?; + } + + Ok(()) +} + +fn read_previous_results() -> Result> { + let results_file_path = get_results_file_path().context("Unable to get results file path")?; + + let mut reader = + csv::Reader::from_path(results_file_path.clone()).context("Unable to create CSV Reader")?; + + let records: Vec = reader + .deserialize() + .collect::>() + .context("Unable to deserialize results")?; + + Ok(records) } fn fmt_num(number: u32) -> String {