DEV Community

Cover image for 😻Build your own CLI version of MonkeyType 🙈
Shrijal Acharya
Shrijal Acharya

Posted on

😻Build your own CLI version of MonkeyType 🙈

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 🐥

Top comments (11)

Collapse
 
aayyusshh_69 profile image
Aayush Pokharel

Long yet beautiful article. Well documented. Thank you

Collapse
 
aayyusshh_69 profile image
Aayush Pokharel

Can I have a link to the full source code?

Collapse
 
shricodev profile image
Shrijal Acharya

I think I have shared it in the article itself. here you go: github.com/shricodev/blogs/tree/ma...
I have also added docstrings to make it easy to understand. 😄

Collapse
 
shricodev profile image
Shrijal Acharya

You're welcome, @aayyusshh_69 👍

Collapse
 
shekharrr profile image
Shekhar Rajput

Bookmarked! Will take a look later. 👍

Collapse
 
shricodev profile image
Shrijal Acharya

Thank you, @shekharrr :)

Collapse
 
coderatul profile image
Atul Kushwaha • Edited

what tool do you use for thumbnail ?

Collapse
 
shricodev profile image
Shrijal Acharya

lexica.art mostly for the AI stock images. leiapix for animations.

Collapse
 
ooosys profile image
oOosys • Edited

Nice explanations and a nice motivation to come up with own typing speed game!
After stripping the class overhead and fixing some issues (e.g. with more code as necessary or with blinking of the speed and accuracy output) the number of code lines shrinks from a total in two files of 164 to 79 lines in a single file. Here how it looks like:

Image description

#!/usr/bin/python3
# https://dev.to/shricodev/build-your-own-cli-version-of-monkeytype-bm7
from curses import *
import random
from time import time as T
tgtLines=open("typing_texts.txt").read().split("\n")
clrEnd=12*" "
def main(stdscr):
    curs_set(0) # 0 -> invisible cursor
    init_pair(1,0,2); blackOnGreen  =color_pair(1)
    init_pair(2,0,1); blackOnRed        =color_pair(2)
    init_pair(3,0,7); blackOnWhite  =color_pair(3)
    init_pair(4,0,3); blackOnYellow =color_pair(4)
    stdscr.clear()
    stdscr.addstr(2,8,"     Welcome to the speed type test")
    stdscr.addstr(3,8,"Press any key to start playing the game!")
    stdscr.getkey()
    nextRound=1
    while nextRound: # of typing a Line
        usrLine = []
        tgtLine = random.choice(tgtLines)
        lenTgt = len(tgtLine)
        stdscr.clear() # make the Terminal text area blank (free of any text) 
        stdscr.addstr(3-1,9-1, tgtLine, blackOnWhite) 
        # ^-- write the text of the line at third line starting with 9-th character
        usrLine.append( stdscr.getkey() ) # wait for pressing a key on the keyboard 
        sT = T()-0.15 # time starts running after first char of the Line is typed
        # ^-- speed of typing the first character is deliberately set to 400 chars/min
        if usrLine[-1]  == "\x1B":  # exit the program with the Esc key
            nextRound = 0; continue # <-- equivalent to  using  'break' instead  

        runTest=1
        while runTest:
            lenUsr = len(usrLine)
            if lenUsr == lenTgt: # <- entire text line was typed in
                stdscr.addstr(3, 8, "  CONGRATULATIONS! (next:'Esc',quit:'Esc,Esc')")
                stdscr.addstr(0, 8, f"'{usrLine[-1]}'"+clrEnd)
                stdscr.addstr(3-1,9-1+(lenUsr-1), tgtLine[lenUsr-1], 
                    blackOnGreen if usrLine[-1]==tgtLine[lenUsr-1] else blackOnRed )
                stdscr.addstr(8, 8, f"{''.join(usrLine):{lenTgt}s}", blackOnWhite)
                stdscr.getkey()
                runTest = 0
                continue
            if usrLine[-1]=="KEY_BACKSPACE": 
                #     [-1] means last item/character in list/string
                stdscr.addstr(0, 8, f"'{usrLine[-1]}'"+clrEnd)
                usrLine=usrLine[:-2] # remove two entries from the list
                if lenUsr >= 2:
                    stdscr.addstr(2, 8+(lenUsr-2), tgtLine[lenUsr-2], blackOnWhite)
                usrLine.append( stdscr.getkey() )
                continue
            if usrLine[-1]  == "\x1B":      # stop typing the line with Esc
                runTest = 0; continue   # <-- equivalent to  using  'break' instead  
            if len(usrLine[-1]) > 1: # special key typed ... IGNORE
                usrLine.pop(); usrLine.append( stdscr.getkey() ) # get another one
                continue

            # --- ^ -- special cases are handled - handling default case --v: 

            stdscr.addstr(0, 8, f"'{usrLine[-1]}'"+clrEnd)
            dT=T()-sT
            stdscr.addstr(5, 8,
    f" Speed   : { int( 60 * lenUsr / (T()-sT) ):3d} Char/min ",    blackOnYellow)
            stdscr.addstr(6, 8, 
f" Accuracy: {int( 100 * sum([ 1 for i in range(lenUsr) if usrLine[i]==tgtLine[i] ]) / lenUsr ):3d}    %     ", 
                blackOnYellow)
            stdscr.addstr(3-1,9-1+(lenUsr-1), tgtLine[lenUsr-1], 
                blackOnGreen if usrLine[-1]==tgtLine[lenUsr-1] else blackOnRed )
            stdscr.addstr(8, 8, f"{''.join(usrLine):{lenTgt}s}", blackOnWhite)
            stdscr.refresh()

            # Now wait for the next input character 
            usrLine.append( stdscr.getkey() )
            continue # and continue running the loop (THIS line can be skipped/removed)

if __name__ == "__main__":
    wrapper(main) # has the advantage to restore Terminal session on Errors
    # using: stdscr = initscr(); start_color() would allow skipping def main(stdscr)
    #   but in case of errors the Terminal would be screwed (e.g. echo disabled).
Enter fullscreen mode Exit fullscreen mode
Collapse
 
shricodev profile image
Shrijal Acharya

@ooosys Wow, it's great that you created your custom version of this. 😄
Doing it your way is wonderful, but I wanted to make it more modular and somewhat maintainable. I have a small take on this: it is not just about the number of code lines but also about whether it is understandable and somewhat about the prod application type.

Collapse
 
ooosys profile image
oOosys

Yes, this is why I suggest that the 'oOo way' is another one for each individual, What is better understandable for you is harder to understand for me and if I infer it right from what you have written the same is the other way also the case (you prefer higher level of hierarchy considering it better understandable, I consider it an obstacle on the way to understanding). What remains as common part is the English language required to be less different from individual to individual in order to play as means for interpersonal exchange of ideas, You are for example spending 21 MByte of Internet bandwidth to share an image in an article which essence are 21 KByte of actual useful data - this makes a factor of 1000 times more for the size of the decoration. It's like spending 21 million Dollar on paining the walls of a house worth 21 thousand $.
Never mind, more ore less it does not really matter as the amount of people reading THIS is so small that the impact is as good as none for both versions and the majority of people will stick with 10fastfingers or monkeytype (for me a hint what the site thinks about its users).