From c4d75eddc262745e1ce2ffcbace7f0d526eebd05 Mon Sep 17 00:00:00 2001 From: radlinskii Date: Tue, 17 Oct 2023 16:35:11 +0200 Subject: [PATCH] feat: add subcommand for showing history of results --- src/args.rs | 17 +++ src/main.rs | 73 +++++++------ src/test_results.rs | 249 ++++++++++++++++++++++++++------------------ 3 files changed, 207 insertions(+), 132 deletions(-) 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/main.rs b/src/main.rs index c81b867..79e2710 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,6 +84,7 @@ use crossterm::{ }; use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; +use test_results::{read_previous_results, render_results}; use args::Args; use config::Config; @@ -99,51 +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 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 { - if let Err(err) = test_results.render_results(&mut terminal) { - eprintln!("{:?}", err); - - restore_terminal(terminal).context("Unable to restore terminal")?; - return Err(err); - } + 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); - - restore_terminal(terminal).context("Unable to restore terminal")?; - 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."); } - - restore_terminal(terminal).context("Unable to restore terminal")?; Ok(()) } - Err(err) => { - println!("Error: {:?}", err); - - restore_terminal(terminal).context("Unable to restore terminal")?; + }; - Err(err) + match res { + Err(err) => { + restore_terminal(&mut terminal).context("Unable to restore terminal")?; + eprintln!("{:?}", err); + return Err(err); } + Ok(_) => Ok(()), } } @@ -171,7 +178,7 @@ 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!(supports_keyboard_enhancement(), Ok(true)) { @@ -253,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)?; @@ -286,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/test_results.rs b/src/test_results.rs index cdad793..e313754 100644 --- a/src/test_results.rs +++ b/src/test_results.rs @@ -1,6 +1,6 @@ //! 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}; @@ -15,7 +15,7 @@ use ratatui::{ }; use serde::{Deserialize, Serialize}; -use std::{fs::create_dir_all, path::PathBuf, rc::Rc, thread::sleep, time::Duration}; +use std::{fs::create_dir_all, path::PathBuf, thread::sleep, time::Duration}; use crate::{ config::Config, @@ -120,12 +120,12 @@ impl TestResults { let results_file_path = get_results_file_path().context("Unable to ge results file path")?; - let records = read_previous_results().context("Unable to read previous 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 { + for record in &results { writer .serialize(record) .context("Unable to serialize one of previous results")?; @@ -142,9 +142,11 @@ impl TestResults { Ok(()) } - 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()); + /// 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| { @@ -180,8 +182,8 @@ impl TestResults { ); let mut frame_wrapper = FrameWrapper::new(frame); - self.render_stats(&mut frame_wrapper, &areas); - self.render_chart(&mut frame_wrapper, &areas, &records); + 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")? { @@ -200,92 +202,15 @@ 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]>) { + /// renders numeric statistics of the current test + fn render_stats(&self, frame: &mut impl FrameWrapperInterface, areas: &[Rect]) { if let Some(wpm) = self.wpm { - frame.render_widget(Paragraph::new(format!("WPM: {:.2}", wpm)), areas[1]); + frame.render_widget(Paragraph::new(format!("WPM: {:.2}", wpm)), areas[0]); } if let Some(raw_accuracy) = self.raw_accuracy { frame.render_widget( Paragraph::new(format!("Raw accuracy: {:.2}%", raw_accuracy)), - areas[2], + areas[1], ); } if let Some(raw_valid_characters_count) = self.raw_valid_characters_count { @@ -294,13 +219,13 @@ impl TestResults { "Raw valid characters: {}", raw_valid_characters_count )), - areas[3], + areas[2], ); } if let Some(raw_mistakes_count) = self.raw_mistakes_count { frame.render_widget( Paragraph::new(format!("Raw mistakes: {}", raw_mistakes_count)), - areas[4], + areas[3], ); } if let Some(raw_typed_characters_count) = self.raw_typed_characters_count { @@ -309,13 +234,13 @@ impl TestResults { "Raw characters typed: {}", raw_typed_characters_count )), - areas[5], + areas[4], ); } if let Some(accuracy) = self.accuracy { frame.render_widget( Paragraph::new(format!("Accuracy after corrections: {:.2}%", accuracy)), - areas[6], + areas[5], ); } if let Some(valid_characters_count) = self.valid_characters_count { @@ -324,13 +249,13 @@ impl TestResults { "Valid characters after corrections: {}", valid_characters_count )), - areas[7], + areas[6], ); } if let Some(mistakes_count) = self.mistakes_count { frame.render_widget( Paragraph::new(format!("Mistakes after corrections: {}", mistakes_count)), - areas[8], + areas[7], ); } @@ -340,12 +265,136 @@ impl TestResults { "Characters typed after corrections: {}", typed_characters_count, )), - areas[9], + 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) + .yellow(), + 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")? @@ -375,18 +424,18 @@ fn create_results_dir_if_not_exist() -> Result<()> { Ok(()) } -fn read_previous_results() -> Result> { +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 records: Vec = reader + let results: Vec = reader .deserialize() .collect::>() .context("Unable to deserialize results")?; - Ok(records) + Ok(results) } fn fmt_num(number: u32) -> String {