Skip to content

Commit

Permalink
feat: show previous results (#26)
Browse files Browse the repository at this point in the history
* feat: render current results using tui

* render results with dates

* refactor: extract some functions

* feat: add subcommand for showing history of results

* Update README.md

* change info color to green

* always show expected input
  • Loading branch information
radlinskii authored Oct 18, 2023
1 parent 01294cd commit 0c102e5
Show file tree
Hide file tree
Showing 8 changed files with 408 additions and 85 deletions.
27 changes: 22 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 <Esc>, to resume it press `'e'` again.
You can pause the test by pressing `<Esc>`, 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).

Expand All @@ -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
```

<img width="1426" alt="picture demonstraiting bar chart with history data" src="https://github.com/radlinskii/donkeytype/assets/26116041/352c68fc-28a3-4ea2-8800-d74b8d759ddd">


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_).
Expand Down
2 changes: 1 addition & 1 deletion cspell.json
Original file line number Diff line number Diff line change
@@ -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"}
17 changes: 17 additions & 0 deletions src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,21 @@ pub struct Args {
/// indicates if test results should be saved
#[arg(long)]
pub save_results: Option<bool>,

/// Add subcommands here
#[command(subcommand)]
pub history: Option<SubCommand>,
}

#[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<bool>,
}
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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");
Expand All @@ -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");

Expand All @@ -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");
Expand Down
18 changes: 14 additions & 4 deletions src/expected_input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -91,16 +92,25 @@ fn replace_words_with_numbers(
return change_to_num_threshold - 1;
}

fn create_uppercase_words(string_vec: &mut Vec<String>, pos: usize, uppercase_ratio: f64) {
fn create_uppercase_words(
string_vec: &mut Vec<String>,
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<char> = 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
Expand Down
79 changes: 46 additions & 33 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -100,54 +100,65 @@ 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(()),
}
}

/// prepares terminal window for rendering using tui
fn configure_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>, 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(
Expand All @@ -167,10 +178,10 @@ fn configure_terminal() -> Result<Terminal<CrosstermBackend<io::Stdout>>, anyhow

/// restores terminal window configuration
fn restore_terminal(
mut terminal: Terminal<CrosstermBackend<io::Stdout>>,
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
) -> 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")?;
}
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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)?;
Expand Down
2 changes: 1 addition & 1 deletion src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading

0 comments on commit 0c102e5

Please sign in to comment.