😻Build your own CLI version of MonkeyType 🙈
Tags: python, beginners, tutorial, programming
Published: 2024-02-18 13:13:24

## 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/unzjcx4ues1ot7m4h4hu.gif)

***

## 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:

```bash
mkdir cli-monkeytype-python
cd cli-monkeytype-python
```

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

```bash
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.

```python
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!

![Showtime GIF](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/i0r36i2t9mrygd14wtop.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.

```Python
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](https://github.com/shricodev/blogs/blob/main/cli-monkeytype-python/typing_texts.txt).

```Python
    # --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.

<br/>

🎯 `display_wpm(self)`: Displays the WPM on the screen as the user types, in the first row.

<br/>

🎯 `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.

<br/>

🎯 `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).

<br/>

🎯 `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:

```Python
    # --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.

<br/>

🎯 `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:

```Python
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. 🔥

![Typing Test Demo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t0eregzw35f8yq66blja.gif)

> 🫵 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](https://github.com/shricodev/IPO-Monitor-NEPSE) 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. 👇

{% cta https://twitter.com/shricodev
 %} Follow me on Twitter 🐥 {% endcta %}

{% embed https://dev.to/shricodev %}