😻Build your own CLI version of MonkeyType 🙈

Shrijal Acharya - Feb 18 - - Dev Community

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.

Monkey typing on a Laptop


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
Enter fullscreen mode Exit fullscreen mode

Create two new files, where we will code the program:

touch main.py typing_test.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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!

Showtime GIF

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--
Enter fullscreen mode Exit fullscreen mode

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--
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

🎯 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)
Enter fullscreen mode Exit fullscreen mode

💡 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. 🔥

Typing Test Demo

🫵 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. 👇

Follow me on Twitter 🐥

. . . . . . . . . . . . . . . . . . .