diff --git a/README.md b/README.md index 80fc408..7a2f385 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ a _very_ minimalistic _cli_ typing test. -![donkeytype demonstration](https://github.com/radlinskii/donkeytype/assets/26116041/ecd835f5-e50b-4bc6-aea4-75f9ecde5de7) +![gif demonstraiting how the program works](https://github.com/radlinskii/donkeytype/assets/26116041/4c2a1b6d-e70e-4631-8438-9259cc780a36) + ## How it works @@ -15,7 +16,7 @@ or press `backspace` while holding `Option`/`Ctrl` to delete a whole word. On the bottom-right corner is a help message saying that to start the test you need to press `'e'` (enter the test) or leave by pressing `'q'` When test is running you can see how much time you have left in bottom-left corner. -You can pause the test by pressing , to resume it press `'e'` again. +You can pause the test by pressing ``, to resume it press `'e'` again. WPM (words per minute) score is calculated as amount of typed characters divided by 5 (word), divided by the duration normalized to 60 seconds (minute). @@ -24,17 +25,33 @@ WPM (words per minute) score is calculated as amount of typed characters divided ### Installation For now there is no deployment environment setup. -You can clone the repo, and run the main program with cargo: +You can clone the repo, and run the main program with default configuration using cargo: ```shell cargo run ``` -To start the program with default config. + +To view the history of results in a bar chart you can run: + +```shell +cargo run -- history +``` + +picture demonstraiting bar chart with history data + + +To see all available options run: + +```shell +cargo run -- --help +``` > So far it was only tested on MacOS. +> Needs testing on Linux +> Not supporting Windows yet (different file paths) -### Configuring +### Configuration For now there are only three options that are read from config. Configuration will grow when more features are added (_different modes_, _different languages_, _configuring colors_). diff --git a/cspell.json b/cspell.json index 3dfa75f..dbdcf62 100644 --- a/cspell.json +++ b/cspell.json @@ -1 +1 @@ -{"words":["crossterm","ratatui","donkeytype","tempfile","mockall","automock","foobarbaaz","foobarbazqux","withf","rngs","Szewnia","chrono","datetime"],"flagWords":[],"language":"en","version":"0.2"} +{"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/args.rs b/src/args.rs index f1a4f92..a346603 100644 --- a/src/args.rs +++ b/src/args.rs @@ -34,4 +34,21 @@ pub struct Args { /// indicates if test results should be saved #[arg(long)] pub save_results: Option, + + /// Add subcommands here + #[command(subcommand)] + pub history: Option, +} + +#[derive(Parser, Debug, Clone)] +pub enum SubCommand { + #[command(about = "Show previous test results in a bar chart.")] + History(HistorySubcommandArgs), +} + +#[derive(Parser, Debug, Clone)] +pub struct HistorySubcommandArgs { + // Define subcommand-specific arguments here + // #[arg(short, long)] + // pub show_date: Option, } diff --git a/src/config.rs b/src/config.rs index c67bd6a..bfff9a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -261,6 +261,7 @@ mod tests { uppercase: None, uppercase_ratio: None, save_results: None, + history: None, }; let config = Config::new(args, PathBuf::new()).expect("Unable to create config"); @@ -284,6 +285,7 @@ mod tests { uppercase: None, uppercase_ratio: None, save_results: None, + history: None, }; let config = Config::new(args, config_file.path().to_path_buf()).expect("Unable to create config"); @@ -303,6 +305,7 @@ mod tests { uppercase: None, uppercase_ratio: None, save_results: Some(false), + history: None, }; let config = Config::new(args, PathBuf::new()).expect("Unable to create config"); @@ -327,6 +330,7 @@ mod tests { uppercase: None, uppercase_ratio: None, save_results: Some(true), + history: None, }; let config = Config::new(args, config_file.path().to_path_buf()).expect("Unable to create config"); 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 7f20646..79e2710 100644 --- a/src/main.rs +++ b/src/main.rs @@ -76,15 +76,15 @@ mod test_results; use anyhow::{Context, Result}; use clap::Parser; +use crossterm::execute; +use crossterm::terminal::supports_keyboard_enhancement; use crossterm::{ event::{KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags}, - execute, - terminal::{ - self, disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, - }, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use test_results::{read_previous_results, render_results}; use args::Args; use config::Config; @@ -100,46 +100,57 @@ use runner::Runner; /// - restores terminal configuration /// - if test was completed, prints the results and saves them. fn main() -> anyhow::Result<()> { - let config_file_path = dirs::home_dir() - .context("Unable to get home directory")? - .join(".config") - .join("donkeytype") - .join("donkeytype-config.json"); - let args = Args::parse(); - let config = Config::new(args, config_file_path).context("Unable to create config")?; - let expected_input = ExpectedInput::new(&config).context("Unable to create expected input")?; - let mut terminal = configure_terminal().context("Unable to configure terminal")?; - let mut app = Runner::new(config, expected_input); - let res = app.run(&mut terminal); + let mut terminal = configure_terminal().context("Unable to configure terminal")?; - restore_terminal(terminal).context("Unable to restore terminal")?; + let res = match &args.history { + Some(_) => { + let records = read_previous_results().context("Unable to read history results")?; + render_results(&mut terminal, &records).context("Unable to render history results")?; + restore_terminal(&mut terminal).context("Unable to restore terminal")?; + Ok(()) + } + None => { + let config_file_path = dirs::home_dir() + .context("Unable to get home directory")? + .join(".config") + .join("donkeytype") + .join("donkeytype-config.json"); + let config = Config::new(args, config_file_path).context("Unable to create config")?; + let expected_input = + ExpectedInput::new(&config).context("Unable to create expected input")?; + + let mut app = Runner::new(config, expected_input); + let test_results = app + .run(&mut terminal) + .context("Error while running the test")?; - match res { - Ok(test_results) => { if test_results.completed { - println!("Test completed.\n"); - test_results.print_stats(); - + test_results + .render(&mut terminal) + .context("Unable to render test results")?; if test_results.save { - if let Err(err) = test_results.save_to_file() { - eprintln!("{:?}", err); - - return Err(err); - } + test_results + .save_to_file() + .context("Unable to save results to file")?; } + restore_terminal(&mut terminal).context("Unable to restore terminal")?; } else { + restore_terminal(&mut terminal).context("Unable to restore terminal")?; println!("Test not finished."); } - Ok(()) } - Err(err) => { - println!("Error: {:?}", err); + }; - Err(err) + match res { + Err(err) => { + restore_terminal(&mut terminal).context("Unable to restore terminal")?; + eprintln!("{:?}", err); + return Err(err); } + Ok(_) => Ok(()), } } @@ -147,7 +158,7 @@ fn main() -> anyhow::Result<()> { fn configure_terminal() -> Result>, anyhow::Error> { enable_raw_mode().context("Unable to enable raw mode")?; let mut stdout = io::stdout(); - if matches!(terminal::supports_keyboard_enhancement(), Ok(true)) { + if matches!(supports_keyboard_enhancement(), Ok(true)) { execute!( stdout, PushKeyboardEnhancementFlags( @@ -167,10 +178,10 @@ fn configure_terminal() -> Result>, anyhow /// restores terminal window configuration fn restore_terminal( - mut terminal: Terminal>, + terminal: &mut Terminal>, ) -> Result<(), anyhow::Error> { disable_raw_mode().context("Unable to disable raw mode")?; - if matches!(terminal::supports_keyboard_enhancement(), Ok(true)) { + if matches!(supports_keyboard_enhancement(), Ok(true)) { execute!(terminal.backend_mut(), PopKeyboardEnhancementFlags) .context("Unable to pop keyboard enhancement flags")?; } @@ -249,6 +260,7 @@ mod tests { uppercase_ratio: None, numbers_ratio: None, save_results: None, + history: None, }; let (config, expected_input, mut terminal) = setup_terminal(args)?; @@ -282,6 +294,7 @@ mod tests { numbers: None, numbers_ratio: None, save_results: None, + history: None, }; let (config, expected_input, mut terminal) = setup_terminal(args)?; diff --git a/src/runner.rs b/src/runner.rs index 1fd8ae2..c04b3a1 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -297,7 +297,7 @@ impl Runner { for ((input_char_index, input_char), expected_input_char) in self.input.chars().enumerate().zip(expected_input.chars()) { - let input: Paragraph<'_> = Paragraph::new(input_char.to_string()).style( + let input: Paragraph<'_> = Paragraph::new(expected_input_char.to_string()).style( match input_char == expected_input_char { true => Style::default() .bg(self.config.colors.correct_match_bg) diff --git a/src/test_results.rs b/src/test_results.rs index 119df49..b968bee 100644 --- a/src/test_results.rs +++ b/src/test_results.rs @@ -1,15 +1,26 @@ //! Module with test statistics structure. //! -//! Used to display to the user results of the test +//! Used to display to the user results of the current and previous tests //! and save those results and configuration of the test to a file. use anyhow::{Context, Result}; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Datelike, Local, Timelike}; +use crossterm::event::{self, Event, KeyCode}; +use ratatui::{ + prelude::{Backend, Constraint, Direction, Layout, Rect}, + style::{Style, Stylize}, + widgets::{Bar, BarGroup, Block}, + widgets::{BarChart, Paragraph}, + Terminal, +}; use serde::{Deserialize, Serialize}; -use std::fs::create_dir_all; +use std::{fs::create_dir_all, path::PathBuf, 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)] @@ -103,33 +114,18 @@ 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"); + pub fn save_to_file(&self) -> Result<(), anyhow::Error> { + 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 results = 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")?; - for record in &records { - println!("{}", record.local_datetime); + for record in &results { writer .serialize(record) .context("Unable to serialize one of previous results")?; @@ -146,40 +142,306 @@ impl TestResults { Ok(()) } - /// prints statistics in an easy to read format - pub fn print_stats(&self) { + /// slightly modified version of `render_results` function + /// uses different layout and renders current test stats in addition to previous results + pub fn render(&self, terminal: &mut Terminal) -> Result<()> { + let mut results = read_previous_results().context("Unable to read previous results")?; + results.push(self.clone()); + + loop { + terminal.draw(|frame| { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(2), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(2), + Constraint::Length(12), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(frame.size()); + + frame.render_widget(Paragraph::new("Test completed"), areas[0]); + frame.render_widget( + Paragraph::new("Press to quit") + .alignment(ratatui::prelude::Alignment::Right) + .green(), + areas[0], + ); + + let mut frame_wrapper = FrameWrapper::new(frame); + self.render_stats(&mut frame_wrapper, &areas[1..10]); + render_chart(&mut frame_wrapper, &areas[10..14], &results); + })?; + + if event::poll(Duration::from_millis(100)).context("Unable to poll for event")? { + if let Event::Key(key) = event::read().context("Unable to read event")? { + match key.code { + KeyCode::Esc => { + break; + } + _ => {} + } + } + } + sleep(Duration::from_millis(100)); + } + + Ok(()) + } + + /// renders numeric statistics of the current test + fn render_stats(&self, frame: &mut impl FrameWrapperInterface, areas: &[Rect]) { if let Some(wpm) = self.wpm { - println!("WPM: {:.2}", wpm); + frame.render_widget(Paragraph::new(format!("WPM: {:.2}", wpm)), areas[0]); } if let Some(raw_accuracy) = self.raw_accuracy { - println!("Raw accuracy: {:.2}%", raw_accuracy); + frame.render_widget( + Paragraph::new(format!("Raw accuracy: {:.2}%", raw_accuracy)), + areas[1], + ); } if let Some(raw_valid_characters_count) = self.raw_valid_characters_count { - println!("Raw valid characters: {}", raw_valid_characters_count); + frame.render_widget( + Paragraph::new(format!( + "Raw valid characters: {}", + raw_valid_characters_count + )), + areas[2], + ); } if let Some(raw_mistakes_count) = self.raw_mistakes_count { - println!("Raw mistakes: {}", raw_mistakes_count); + frame.render_widget( + Paragraph::new(format!("Raw mistakes: {}", raw_mistakes_count)), + areas[3], + ); } if let Some(raw_typed_characters_count) = self.raw_typed_characters_count { - println!("Raw characters typed: {}", raw_typed_characters_count); + frame.render_widget( + Paragraph::new(format!( + "Raw characters typed: {}", + raw_typed_characters_count + )), + areas[4], + ); } if let Some(accuracy) = self.accuracy { - println!("Accuracy after corrections: {:.2}%", accuracy); + frame.render_widget( + Paragraph::new(format!("Accuracy after corrections: {:.2}%", accuracy)), + areas[5], + ); } if let Some(valid_characters_count) = self.valid_characters_count { - println!( - "Valid characters after corrections: {}", - valid_characters_count + frame.render_widget( + Paragraph::new(format!( + "Valid characters after corrections: {}", + valid_characters_count + )), + areas[6], ); } if let Some(mistakes_count) = self.mistakes_count { - println!("Mistakes after corrections: {}", mistakes_count); + frame.render_widget( + Paragraph::new(format!("Mistakes after corrections: {}", mistakes_count)), + areas[7], + ); } + if let Some(typed_characters_count) = self.typed_characters_count { - println!( - "Characters typed after corrections: {}", - typed_characters_count + frame.render_widget( + Paragraph::new(format!( + "Characters typed after corrections: {}", + typed_characters_count, + )), + areas[8], ); } } } + +/// creates rendering loop and passes provided test results vector to render_chart function +pub fn render_results( + terminal: &mut Terminal, + results: &Vec, +) -> Result<()> { + loop { + terminal.draw(|frame| { + let areas = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Length(2), + Constraint::Length(12), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ] + .as_ref(), + ) + .split(frame.size()); + + frame.render_widget( + Paragraph::new("Press to quit") + .alignment(ratatui::prelude::Alignment::Right) + .green(), + areas[0], + ); + + let mut frame_wrapper = FrameWrapper::new(frame); + render_chart(&mut frame_wrapper, &areas[1..5], &results); + })?; + + if event::poll(Duration::from_millis(100)).context("Unable to poll for event")? { + if let Event::Key(key) = event::read().context("Unable to read event")? { + match key.code { + KeyCode::Esc => { + break; + } + _ => {} + } + } + } + sleep(Duration::from_millis(100)); + } + + Ok(()) +} + +/// renders BarChart widget from ratatui crate +/// displaying WPM values of provided TestResults +/// and adding dates of the tests as their custom labels. +fn render_chart( + frame: &mut impl FrameWrapperInterface, + areas: &[Rect], + results: &Vec, +) { + let mut results_to_render = results.clone(); + let bar_width = 5; + let frame_width = frame.size().width; + let bars_to_show = ((frame_width + 1) / (bar_width + 1)) as usize; + if results.len() >= bars_to_show { + results_to_render = results[results.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( + &results_to_render + .iter() + .map(|r| { + Bar::default().value(if let Some(wpm) = r.wpm { wpm as u64 } else { 0 }) + }) + .collect::>(), + ), + ), + areas[0], + ); + frame.render_widget( + Paragraph::new( + results_to_render + .iter() + .map(|r| { + format!( + "{}:{} ", + fmt_num(r.local_datetime.hour()), + fmt_num(r.local_datetime.minute()) + ) + }) + .collect::(), + ), + areas[1], + ); + frame.render_widget( + Paragraph::new( + results_to_render + .iter() + .map(|r| { + format!( + "{}/{} ", + fmt_num(r.local_datetime.month()), + fmt_num(r.local_datetime.day()) + ) + }) + .collect::(), + ), + areas[2], + ); + frame.render_widget( + Paragraph::new( + results_to_render + .iter() + .map(|r| format!("{} ", r.local_datetime.year())) + .collect::(), + ), + areas[3], + ); +} + +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(()) +} + +pub 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 results: Vec = reader + .deserialize() + .collect::>() + .context("Unable to deserialize results")?; + + Ok(results) +} + +fn fmt_num(number: u32) -> String { + if number < 10 { + format!("0{}", number) + } else { + format!("{}", number) + } +}