Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: show previous results #26

Merged
merged 8 commits into from
Oct 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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