pub mod config;
pub mod results;
pub mod textgen;
pub mod tui;
pub mod wordlists;
use std::io::StdinLock;
use std::path::PathBuf;
use std::time::Instant;
use config::ToipeConfig;
use results::ToipeResults;
use termion::input::Keys;
use termion::{color, event::Key, input::TermRead};
use textgen::{PunctuatedWordSelector, RawWordSelector, WordSelector};
use tui::{Text, ToipeTui};
use wordlists::{BuiltInWordlist, OS_WORDLIST_PATH};
use anyhow::{Context, Result};
pub struct Toipe {
tui: ToipeTui,
text: Vec<Text>,
words: Vec<String>,
word_selector: Box<dyn WordSelector>,
config: ToipeConfig,
}
#[derive(Debug)]
pub struct ToipeError {
pub msg: String,
}
impl ToipeError {
pub fn with_context(mut self, context: &str) -> Self {
self.msg = context.to_owned() + &self.msg;
self
}
}
impl From<String> for ToipeError {
fn from(error: String) -> Self {
ToipeError { msg: error }
}
}
impl std::fmt::Display for ToipeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(format!("ToipeError: {}", self.msg).as_str())
}
}
impl std::error::Error for ToipeError {}
impl<'a> Toipe {
pub fn new(config: ToipeConfig) -> Result<Self> {
let mut word_selector: Box<dyn WordSelector> = if let Some(wordlist_path) =
config.wordlist_file.clone()
{
Box::new(
RawWordSelector::from_path(PathBuf::from(wordlist_path.clone())).with_context(
|| format!("reading the word list from given path '{}'", wordlist_path),
)?,
)
} else if let Some(word_list) = config.wordlist.contents() {
Box::new(
RawWordSelector::from_string(word_list.to_string()).with_context(|| {
format!("reading the built-in word list {:?}", config.wordlist)
})?,
)
} else if let BuiltInWordlist::OS = config.wordlist {
Box::new(
RawWordSelector::from_path(PathBuf::from(OS_WORDLIST_PATH)).with_context(|| {
format!(
"reading from the OS wordlist at path '{}'. See https://en.wikipedia.org/wiki/Words_(Unix) for more info on this file and how it can be installed.",
OS_WORDLIST_PATH
)
})?,
)
} else {
return Err(ToipeError::from("Undefined word list or path.".to_owned()))?;
};
if config.punctuation {
word_selector = Box::new(PunctuatedWordSelector::from_word_selector(
word_selector,
0.15,
))
}
let mut toipe = Toipe {
tui: ToipeTui::new(),
words: Vec::new(),
text: Vec::new(),
word_selector,
config,
};
toipe.restart()?;
Ok(toipe)
}
pub fn restart(&mut self) -> Result<()> {
self.tui.reset_screen()?;
self.words = self.word_selector.new_words(self.config.num_words)?;
self.tui.display_lines_bottom(&[&[
Text::from("ctrl-r").with_color(color::Blue),
Text::from(" to restart, ").with_faint(),
Text::from("ctrl-c").with_color(color::Blue),
Text::from(" to quit ").with_faint(),
]])?;
self.show_words()?;
Ok(())
}
fn show_words(&mut self) -> Result<()> {
self.text = self.tui.display_words(&self.words)?;
Ok(())
}
pub fn test(&mut self, stdin: StdinLock<'a>) -> Result<(bool, ToipeResults)> {
let mut input = Vec::<char>::new();
let original_text = self
.text
.iter()
.fold(Vec::<char>::new(), |mut chars, text| {
chars.extend(text.text().chars());
chars
});
let mut num_errors = 0;
let mut num_chars_typed = 0;
enum TestStatus {
NotDone,
Done,
Quit,
Restart,
}
impl TestStatus {
fn to_process_more_keys(&self) -> bool {
matches!(self, TestStatus::NotDone)
}
fn to_display_results(&self) -> bool {
matches!(self, TestStatus::Done)
}
fn to_restart(&self) -> bool {
matches!(self, TestStatus::Restart)
}
}
let mut process_key = |key: Key| -> Result<TestStatus> {
match key {
Key::Ctrl('c') => {
return Ok(TestStatus::Quit);
}
Key::Ctrl('r') => {
return Ok(TestStatus::Restart);
}
Key::Ctrl('w') => {
while !matches!(input.last(), Some(' ') | None) {
if input.pop().is_some() {
self.tui.replace_text(
Text::from(original_text[input.len()]).with_faint(),
)?;
}
}
}
Key::Char(c) => {
input.push(c);
if input.len() >= original_text.len() {
return Ok(TestStatus::Done);
}
num_chars_typed += 1;
if original_text[input.len() - 1] == c {
self.tui
.display_raw_text(&Text::from(c).with_color(color::LightGreen))?;
self.tui.move_to_next_char()?;
} else {
self.tui.display_raw_text(
&Text::from(original_text[input.len() - 1])
.with_underline()
.with_color(color::Red),
)?;
self.tui.move_to_next_char()?;
num_errors += 1;
}
}
Key::Backspace | Key::Ctrl('h') => {
if input.pop().is_some() {
self.tui
.replace_text(Text::from(original_text[input.len()]).with_faint())?;
}
}
_ => {}
}
self.tui.flush()?;
Ok(TestStatus::NotDone)
};
let mut keys = stdin.keys();
let key = keys.next().unwrap()?;
let started_at = Instant::now();
let mut status = process_key(key)?;
if status.to_process_more_keys() {
for key in &mut keys {
status = process_key(key?)?;
if !status.to_process_more_keys() {
break;
}
}
}
let ended_at = Instant::now();
let (final_chars_typed_correctly, final_uncorrected_errors) =
input.iter().zip(original_text.iter()).fold(
(0, 0),
|(total_chars_typed_correctly, total_uncorrected_errors),
(typed_char, orig_char)| {
if typed_char == orig_char {
(total_chars_typed_correctly + 1, total_uncorrected_errors)
} else {
(total_chars_typed_correctly, total_uncorrected_errors + 1)
}
},
);
let results = ToipeResults {
total_words: self.words.len(),
total_chars_typed: num_chars_typed,
total_chars_in_text: input.len(),
total_char_errors: num_errors,
final_chars_typed_correctly,
final_uncorrected_errors,
started_at,
ended_at,
};
let to_restart = if status.to_display_results() {
self.display_results(results.clone(), keys)?
} else {
status.to_restart()
};
Ok((to_restart, results))
}
fn display_results(
&mut self,
results: ToipeResults,
mut keys: Keys<StdinLock>,
) -> Result<bool> {
self.tui.reset_screen()?;
self.tui.display_lines::<&[Text], _>(&[
&[Text::from(format!(
"Took {}s for {} words of {}",
results.duration().as_secs(),
results.total_words,
self.config.text_name(),
))],
&[
Text::from(format!("Accuracy: {:.1}%", results.accuracy() * 100.0))
.with_color(color::Blue),
],
&[Text::from(format!(
"Mistakes: {} out of {} characters",
results.total_char_errors, results.total_chars_in_text
))],
&[
Text::from("Speed: "),
Text::from(format!("{:.1} wpm", results.wpm())).with_color(color::Green),
Text::from(" (words per minute)"),
],
])?;
self.tui.display_lines_bottom(&[&[
Text::from("ctrl-r").with_color(color::Blue),
Text::from(" to restart, ").with_faint(),
Text::from("ctrl-c").with_color(color::Blue),
Text::from(" to quit ").with_faint(),
]])?;
self.tui.hide_cursor()?;
let mut to_restart: Option<bool> = None;
while to_restart.is_none() {
match keys.next().unwrap()? {
Key::Ctrl('r') => to_restart = Some(true),
Key::Ctrl('c') => to_restart = Some(false),
_ => {}
}
}
self.tui.show_cursor()?;
Ok(to_restart.unwrap_or(false))
}
}