TL;DR
In this easy-to-follow tutorial, you will learn how to build your own CLI version of MonkeyType in minutes. 😎
What you will learn: ✨
- Use the Python curses module to build a robust typing CLI application with WPM and Accuracy support.
Are you ready to become a CLI MonkeyTyper? 😉 Whether this is your first CLI application or nth application. Feel free to follow along.
Setting up the Environment 🙈
ℹ️ There is no need to set up a virtual environment, we will not be using any external dependencies.
Create a folder to keep all your source code for the project:
mkdir cli-monkeytype-python
cd cli-monkeytype-python
Create two new files, where we will code the program:
touch main.py typing_test.py
The main.py
file will act as the starting point of our application and the typing_test.py
file will hold all the logic of the program.
ℹ️ For Linux or Mac users, you don't need to download any dependencies, we will mainly be using curses, time, and random modules which are all included in the Python Standard Library.
⚠️ Note
Windows users might have to install curses as it is not included in the Python Standard Library for Windows. Make sure to have it installed before proceeding further.
Let's Code it up 🐵
💡 We will look into the Approach, Outline, and the actual coding portion of the application in this section. 😵💫
Approach and Outline 👀
We will be taking a different approach here, instead of jamming all the code in the main
file. We will split the codes into classes in a different file.
There will be a separate file containing a class responsible for encapsulating all the logic related to the Typing Test. In the main file, we will then invoke the methods from this class. Sounds, right? Let's get into it. 🚀
Here, is the skeleton of our class and all the methods that we are going to be working on.
class TypingTest:
def __init__(self, stdscr):
pass
def get_line_to_type(self):
pass
def display_wpm(self):
pass
def display_accuracy(self):
pass
def display_typed_chars(self):
pass
def display_details(self):
pass
def test_accuracy(self):
pass
def test_wpm(self):
pass
All the function names should be self-explanatory. If you need help understanding what each function does, even after looking at this outline, why are you even reading the article? Just kidding *not really*. 😏
🥱 This is a beginner-friendly application. Don't worry, code along.
Actual Fun Begins!
We will start by importing modules and coding our __init__
method. This will initialize all the jargon we need for the program to work.
import curses
import random
import time
class TypingTest:
def __init__(self, stdscr):
self.stdscr = stdscr
self.to_type_text = self.get_line_to_type()
self.user_typed_text = []
self.wpm = 0
self.start_time = time.time()
# Initialize color pairs
curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLACK)
curses.init_pair(2, curses.COLOR_RED, curses.COLOR_BLACK)
curses.init_pair(1, curses.COLOR_GREEN, curses.COLOR_BLACK)
# --SNIP--
The stdscr
is used to control the terminal screen and is essential for creating text-based user interfaces where users can see their keystrokes. ⌨️
The get_line_to_type
method gets a line of text for the user to type. That text is stored in the self.to_type_text
variable. As they type, the characters they enter are saved in the self.user_typed_text
list. We use a list because it will be easier to pop the last item when the user corrects their mistyped character.
The initial words per minute (WPM) score is set to 0, and we record the start time of the test. We also initialize a few color pairs that we will use to indicate colors on the characters based on whether they are correct or not. Later, we’ll calculate the WPM based on how long it takes the user to type.
Now, add the code for the following functions
ℹ️ Make sure to create a new file named
typing_texts.txt
in the project root with a few lines of text. For reference: click here.
# --SNIP--
def get_line_to_type(self):
with open("typing_texts.txt", "r", encoding="utf-8") as file:
lines = file.readlines()
return random.choice(lines).strip()
def display_wpm(self):
self.stdscr.addstr(1, 0, f"WPM: {self.wpm}", curses.color_pair(3))
def display_accuracy(self):
self.stdscr.addstr(
2,
0,
f"Accuracy: {self.test_accuracy()}%",
curses.color_pair(3),
)
def display_typed_chars(self):
for i, char in enumerate(self.user_typed_text):
correct_character = self.to_type_text[i]
# Use color pair 1 if correct, else color pair 2.
color = 1 if char == correct_character else 2
self.stdscr.addstr(0, i, char, curses.color_pair(color))
def display_details(self):
self.stdscr.addstr(self.to_type_text)
self.display_wpm()
self.display_accuracy()
self.display_typed_chars()
# --SNIP--
Let me summarise these methods, they are pretty straightforward:
🎯 get_line_to_type(self)
: Retrieves a random line from a file named "typing_texts.txt" with removed trailing spaces.
🎯 display_wpm(self)
: Displays the WPM on the screen as the user types, in the first row.
🎯 display_accuracy(self)
: Displays the accuracy percentage on the screen at row 2. The accuracy is calculated by the test_accuracy()
method which we will write soon.
🎯 display_typed_chars(self)
: Displays the characters the user has typed on the screen, highlighting correct characters in one color pair (color 1) and incorrect characters in another color pair (color 2).
🎯 display_details(self)
: It is essentially a helper function that helps display contents from all the display functions above.
Okay, now that we have written these display methods, let's implement the actual logic to test the accuracy and the WPM itself.
Add the following lines of code:
# --SNIP--
def test_accuracy(self):
total_characters = min(len(self.user_typed_text), len(self.to_type_text))
# If there are no typed chars, show accuracy 0.
if total_characters == 0:
return 0.0
matching_characters = 0
for current_char, target_char in zip(self.user_typed_text, self.to_type_text):
if current_char == target_char:
matching_characters += 1
matching_percentage = (matching_characters / total_characters) * 100
return matching_percentage
def test_wpm(self):
# getkey method by default is blocking.
# We do not want to wait until the user types each char to check WPM.
# Else the entire logic will be faulty.
self.stdscr.nodelay(True)
while True:
# Since we have nodelay = True, if not using max(),
# users might end up with time.time() equal to start_time,
# resulting in 0 and potentially causing a zero-divisible error in the below line.
time_elapsed = max(time.time() - self.start_time, 1)
# Considering the average word length in English is 5 characters
self.wpm = round((len(self.user_typed_text) / (time_elapsed / 60)) / 5)
self.stdscr.clear()
self.display_details()
self.stdscr.refresh()
# Exit the loop when the user types in the total length of the text.
if len(self.user_typed_text) == len(self.to_type_text):
self.stdscr.nodelay(False)
break
# We have `nodelay = True`, so we don't want to wait for the keystroke.
# If we do not get a key, it will throw an exception
# in the below lines when accessing the key.
try:
key = self.stdscr.getkey()
except Exception:
continue
# Check if the key is a single character before using ord()
if isinstance(key, str) and len(key) == 1:
if ord(key) == 27: # ASCII value for ESC
break
# If the user has not typed anything reset to the current time
if not self.user_typed_text:
self.start_time = time.time()
if key in ("KEY_BACKSPACE", "\b", "\x7f"):
if len(self.user_typed_text) > 0:
self.user_typed_text.pop()
elif len(self.user_typed_text) < len(self.to_type_text):
self.user_typed_text.append(key)
🎯 test_accuracy(self)
: Calculates and returns the typing accuracy as a percentage by comparing the characters typed by the user with the target text. If the character matches, it increases the count of matching characters by 1. In the end, it calculates the percentage of the match.
🎯 test_wpm(self)
: Calculates the words per minute (WPM) and updates the display in real time. We use a formula to calculate the WPM, it is not something I came up with, I copied it from the internet. It tracks what the user types, handles backspaces, and stops when they finish typing the target text or press ESC.
Great! This is it for our TypingTest class. 🎉
✅ We have written the code in such a way that it will help us easily import this code into any future projects and make maintenance a lot easier.
Time to test our implementation. 🙈
In the main.py
file, add the following lines of code:
from curses import wrapper
from typing_test import TypingTest
def main(stdscr):
stdscr.clear()
stdscr.addstr("Welcome to the typing speed test")
stdscr.addstr("\nPress any key to continue!")
while True:
typing_test = TypingTest(stdscr)
stdscr.getkey()
typing_test.test_wpm()
stdscr.addstr(
3,
0,
"Congratulations! You have completed the test! Press any key to continue...",
)
stdscr.nodelay(False)
key = stdscr.getkey()
# Check if the key is a single character before using ord()
if isinstance(key, str) and len(key) == 1:
if ord(key) == 27: # ASCII value for ESC
break
if __name__ == "__main__":
wrapper(main)
💡 NOTE: we are calling the main function inside the
wrapper
method from curses which handles initialization and cleanup of the curses module.
Inside main, we make an instance of the TypingTest class and run the test in the infinite loop, which lets the user keep on running the tests until they decide to quit by pressing ESC.
Let's see it in action. 🔥
🫵 If you have made it this far, I want to assign you a small task. Currently, we are randomly selecting text from a file for typing. I would like you to scrape typing text from the internet and use that content instead. Feel free to open a pull request in my repository with your changes.
If you need help, I have already worked on a similar Python scraping project. Feel free to check it out.
Wrap Up! 🐒
By now, you have built a Python CLI application to test your typing speed right in your terminal.
The documented source code for this article is available here:
https://github.com/shricodev/blogs/tree/main/cli-monkeytype-python
Thank you so much for reading! 🎉🫡
Drop down your thoughts in the comment section below. 👇