A simple Terminal Typing Test utility written in Rust using ratatui, inspired by Monkeytype.
git clone https://github.com/semanavasco/ttt.git
cd ttt
cargo install --path .$ ttt
A simple Terminal Typing Test utility.
Usage: ttt [OPTIONS] [COMMAND]
Commands:
clock Timer-based game mode
words Wourd-count-based game mode
help Print this message or the help of the given subcommand(s)
Options:
-c, --config <CONFIG> Read config from file
-s, --save-config Save config, applies overrides provided by other arguments
--defaults Use default settings
-h, --help Print help
-V, --version Print version# Run with saved config or defaults
$ ttt
# Run clock mode with 60 second duration
$ ttt clock -d 60
# Run words mode with 100 words using Spanish text
$ ttt words -c 100 -t spanish
# Save current settings as default
$ ttt clock -d 45 -t english --save-config
# Load from custom config file
$ ttt --config ~/my-config.toml| Name | Description |
|---|---|
english |
100 most common English words (default) |
french |
100 most common French words |
german |
100 most common German words |
lorem |
100 words of Lorem Ipsum |
portuguese |
100 most common Portuguese words |
spanish |
100 most common Spanish words |
swedish |
100 most common Swedish words |
Config file location: ~/.config/ttt/config.toml
[defaults]
text = "english"
mode = "clock"
duration = 30CLI arguments override config file settings.
Custom texts can be placed at: ~/.config/ttt/texts/
You can customize the look of the application by adding a [theme] section to your config.toml.
Styles are defined as strings containing space-separated properties:
- Foreground:
fg:<color>(e.g.,fg:red,fg:#ff0000) - Background:
bg:<color>(e.g.,bg:blue) - Underline Color:
ul:<color>(e.g.,ul:green) - Modifiers:
bold,italic,underlined,dim,reversed,hidden,crossed_out
Colors can be specified as:
- Named:
black,red,green,yellow,blue,magenta,cyan,gray,dark_gray,white, and theirlight_variants. - Hex:
#RRGGBB - Indexed:
0-255(ANSI color codes)
| Key | Description | Default |
|---|---|---|
border_type |
Border style (plain, rounded, double, thick) |
rounded |
border_style |
Style of the window borders | reset |
background |
Global background color | reset |
default |
Default text style | reset |
pending |
Future text to be typed | fg:dark_gray |
correct |
Correctly typed text | fg:green bold |
incorrect |
Incorrectly typed text | fg:red bold underlined |
skipped |
Text skipped by backspacing too far or errors | fg:dark_gray underlined ul:red |
cursor |
The current character under the cursor | bg:white fg:dark_gray |
extra |
Extra characters typed (errors) | fg:red bold |
highlighted |
Selected option in menus | fg:magenta bold |
selected |
Option currently being edited | fg:yellow bold underlined |
[theme]
border_type = "double"
pending = "fg:gray"
correct = "fg:#a6da95"
incorrect = "fg:#ed8796 bold"
cursor = "bg:#f5bde6 fg:#24273a"- Create
src/app/modes/newmode.rsand implementHandler+Renderertraits:
use anyhow::Result;
use crate::app::{
events::Action,
modes::{Direction, FooterHint, GameStats, Handler, OptionGroup, OptionItem, Renderer},
ui::char::StyledChar,
};
use crate::config::Config;
use crossterm::event::KeyEvent;
pub struct NewMode {
// your fields
}
impl Handler for NewMode {
fn initialize(&mut self, config: &Config) -> Result<()> {
/* load config, generate words */
Ok(())
}
fn handle_input(&mut self, key: KeyEvent) -> Action {
// Handle typing, backspace, mode-specific shortcuts
// Global keys (ESC, TAB, arrows, ...) are handled before this
Action::None
}
fn reset(&mut self) -> Result<()> {
/* reset to initial state */
Ok(())
}
fn is_complete(&self) -> bool { /* checks for game mode's completion */ }
fn on_complete(&mut self) { /* called when transitioning to State::Complete */ }
}
impl Renderer for NewMode {
fn get_options(&self, focused: Option<usize>) -> OptionGroup {
// Return mode-specific options (e.g., duration, word count)
OptionGroup { items: vec![] }
}
fn select_option(&mut self, index: usize) { /* handle option selection */ }
fn adjust_option(&mut self, index: usize, direction: Direction) { /* adjust value */ }
fn is_option_editing(&self) -> bool { /* whether an option is being edited */ }
fn option_count(&self) -> usize { /* the amount of options available */ }
fn get_progress(&self) -> String { /* string representation of the test's status (e.g., 35/50 words)*/ }
fn get_characters(&self) -> Vec<StyledChar> {
// Return characters with semantic states (Pending, Correct, Incorrect, etc.)
// Global renderer applies theme colors
vec![]
}
fn get_stats(&self) -> GameStats { GameStats::new(0.0, 0.0, 0.0) }
fn get_wpm_data(&self) -> Vec<(f64, f64)> { vec![] }
fn footer_hints(&self) -> Vec<FooterHint> {
// Optional mode-specific key hints, e.g., vec![FooterHint::new("Ctrl+H", "Clear word", vec![State::Running])]
vec![]
}
}- Add to
src/app/modes/mod.rs:
pub mod newmode;
use crate::app::modes::newmode::NewMode;
// Add variant to Mode enum (derives Subcommand for CLI integration)
#[derive(Serialize, Deserialize, Subcommand, Display, EnumIter, VariantNames, Clone)]
#[serde(tag = "mode", rename_all = "lowercase")]
pub enum Mode {
Clock { /* ... */ },
Words { /* ... */ },
NewMode {
// your mode-specific config fields
},
}
// Update create_mode factory
pub fn create_mode(mode: &Mode) -> Box<dyn GameMode> {
match mode {
// ...
Mode::NewMode { /* ... */ } => Box::new(NewMode::new(/* ... */)),
}
}
// Update Mode::default_for helper
impl Mode {
pub fn default_for(name: &str) -> Self {
match name {
// ...
"newmode" => Mode::NewMode { /* ... */ },
_ => Mode::default(),
}
}
}- Open a PR with your new mode (or enjoy it locally...)!
This is one of my first Rust projects and I'm actively learning! I'm open to suggestions, code reviews, and constructive criticism. Feel free to open issues. I'd appreciate if you'd let me fix them rather than opening PRs with written solutions. Thank you!
This project is licensed under the MIT License - see the LICENSE file for details.
