├── .gitignore ├── LICENSE ├── README.md ├── example.gif ├── scripts └── tt ├── setup.py └── typing_test ├── __init__.py ├── __main__.py ├── data └── vocab └── typing_test.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | MANIFEST 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | .pytest_cache/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | db.sqlite3 58 | 59 | # Flask stuff: 60 | instance/ 61 | .webassets-cache 62 | 63 | # Scrapy stuff: 64 | .scrapy 65 | 66 | # Sphinx documentation 67 | docs/_build/ 68 | 69 | # PyBuilder 70 | target/ 71 | 72 | # Jupyter Notebook 73 | .ipynb_checkpoints 74 | 75 | # pyenv 76 | .python-version 77 | 78 | # celery beat schedule file 79 | celerybeat-schedule 80 | 81 | # SageMath parsed files 82 | *.sage.py 83 | 84 | # Environments 85 | .env 86 | .venv 87 | env/ 88 | venv/ 89 | ENV/ 90 | env.bak/ 91 | venv.bak/ 92 | 93 | # Spyder project settings 94 | .spyderproject 95 | .spyproject 96 | 97 | # Rope project settings 98 | .ropeproject 99 | 100 | # mkdocs documentation 101 | /site 102 | 103 | # mypy 104 | .mypy_cache/ 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Emil Lynegaard 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typing Test 2 | ![](example.gif) 3 | 4 | [![PyPI Status](https://img.shields.io/pypi/v/typing_test)](https://pypi.org/project/typing-test/) 5 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/ecly/typing_test/blob/master/LICENSE) 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) 7 | 8 | Minimal command line game for measuring typing speed with no dependencies. 9 | Supports both Python2 and Python3. 10 | Resembles the format from 10fastfingers. 11 | 12 | Supports configurable display format, test duration, word length, vocab and more. 13 | Use CTRL+R to restart and CTRL+C to exit. 14 | See `tt --help` for all options. 15 | 16 | ## Recommended installation 17 | ```sh 18 | python -m pip install typing_test --user 19 | ``` 20 | 21 | ### Manual 22 | ```sh 23 | git clone https://github.com/ecly/typing_test 24 | cd typing_test 25 | python -m pip install . --user 26 | ``` 27 | 28 | ## License 29 | MIT License 30 | 31 | Copyright (c) 2019 Emil Lynegaard 32 | 33 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 34 | 35 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 36 | 37 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 38 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecly/typing_test/a46da4bb53d5383f7835dbc69950c264b8c9d6b8/example.gif -------------------------------------------------------------------------------- /scripts/tt: -------------------------------------------------------------------------------- 1 | #!/bin/env python3 2 | # pylint: disable=missing-docstring 3 | from typing_test import typing_test 4 | typing_test.main() 5 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # pylint: disable=missing-docstring 2 | from setuptools import setup 3 | 4 | with open("README.md", "r") as f: 5 | long_description = f.read() 6 | 7 | setup( 8 | name="typing_test", 9 | version="0.1.0", 10 | description="Typing test in the terminal similar to 10fastfingers", 11 | license="MIT", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | author="Emil Lynegaard", 15 | author_email="ecly@mailbox.org", 16 | url="http://www.github.com/ecly/typing_test", 17 | packages=["typing_test"], 18 | package_data={"typing_test": ["data/vocab"]}, 19 | install_requires=[], 20 | scripts=["scripts/tt"], 21 | keywords=[ 22 | "typing", 23 | "typing test", 24 | "10fastfingers", 25 | "cmd", 26 | "terminal", 27 | "game", 28 | "ncurses", 29 | "curses", 30 | ], 31 | ) 32 | -------------------------------------------------------------------------------- /typing_test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ecly/typing_test/a46da4bb53d5383f7835dbc69950c264b8c9d6b8/typing_test/__init__.py -------------------------------------------------------------------------------- /typing_test/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main wrapper for running game as module. 3 | """ 4 | from .typing_test import main 5 | 6 | if __name__ == "__main__": 7 | main() 8 | -------------------------------------------------------------------------------- /typing_test/typing_test.py: -------------------------------------------------------------------------------- 1 | """ 2 | Copyright (c) 2019 Emil Lynegaard 3 | Distributed under the MIT software license, see the 4 | accompanying LICENSE.md or https://opensource.org/licenses/MIT 5 | 6 | Minimal 10fastfingers like typing game for Python2/3 with ncurses. 7 | Supports vocab files as arguments, as well as adjustable 8 | word length, game time and word frequency (if using sorted vocab). 9 | """ 10 | import argparse 11 | import time 12 | import random 13 | import curses 14 | import textwrap 15 | import os 16 | 17 | # Robust path to default vocabulary, which is based on word frequency 18 | # from CNN and DailyMail articles. 19 | VOCAB_PATH = os.path.join(os.path.dirname(__file__), "data", "vocab") 20 | 21 | # Used for WPM calculation 22 | CHARS_PER_WORD = 5 23 | 24 | # Amount of words to store, for showing several future words 25 | # Should generally be equal to at least two lines worth of words 26 | QUEUE_SIZE = 30 27 | 28 | # Maximum amount of characters to be displayed in a single row 29 | MAX_WIDTH = 80 30 | 31 | # pylint: disable=too-many-instance-attributes 32 | class Game: 33 | """ 34 | Class encapsulating the Game. 35 | Includes game stats, input management, game display. 36 | """ 37 | 38 | CORRECT_COLOR = 1 39 | INCORRECT_COLOR = 2 40 | 41 | def __init__(self, args): 42 | self.word_generator = self._word_generator(args) 43 | self.game_time = args.game_time 44 | self.next_words = [self._get_word() for _ in range(QUEUE_SIZE)] 45 | self.typed = [] 46 | self.correct = [] 47 | self.incorrect = [] 48 | self.input = "" 49 | 50 | self.display = args.display 51 | 52 | # if using 10ff display, we keep track of extra things 53 | if self.display == "10ff": 54 | self.offset = 0 55 | self.current_line = [] 56 | self.next_line = [] 57 | 58 | @staticmethod 59 | def _word_generator(args): 60 | words = [] 61 | for line in open(args.vocab): 62 | word = line.strip() 63 | if args.min_length <= len(word) <= args.max_length: 64 | words.append(word) 65 | 66 | if len(words) >= args.words: 67 | break 68 | 69 | while True: 70 | yield random.choice(words) 71 | 72 | def calculate_cpm(self, time_played): 73 | """Calculate CPM given time_played in seconds""" 74 | if time_played == 0: 75 | return 0 76 | 77 | correct_chars = len(" ".join(self.correct)) 78 | cpm = 60 / time_played * correct_chars 79 | cpm = int(round(cpm)) 80 | return cpm 81 | 82 | def calculate_wpm(self, time_played): 83 | """Calculate WPM given time_played in seconds""" 84 | cpm = self.calculate_cpm(time_played) 85 | wpm = cpm // CHARS_PER_WORD 86 | return wpm 87 | 88 | def _get_word(self): 89 | return next(self.word_generator) 90 | 91 | def _finish_word_event(self): 92 | target = self.next_words.pop(0) 93 | self.typed.append(self.input) 94 | if self.input == target: 95 | self.correct.append(target) 96 | else: 97 | self.incorrect.append(target) 98 | 99 | if self.display == "10ff": 100 | self.offset += 1 101 | 102 | self.next_words.append(self._get_word()) 103 | self.input = "" 104 | 105 | @staticmethod 106 | def _get_line(words, max_chars): 107 | line = [] 108 | chars = 0 109 | for word in words: 110 | length = len(word) 111 | # use +1 to account for added whitespace 112 | if chars + length + 1 > max_chars: 113 | break 114 | 115 | line.append(word) 116 | chars += length + 1 117 | 118 | return line 119 | 120 | def _progressive_display(self, stdscr, time_left): 121 | _height, width = stdscr.getmaxyx() 122 | width = min(width, MAX_WIDTH) 123 | 124 | stdscr.clear() 125 | wpm = self.calculate_wpm(self.game_time - time_left) 126 | stdscr.addstr("Time left: {:d}, WPM: {:d}\n".format(time_left, wpm)) 127 | 128 | line = self._get_line(self.next_words, width) 129 | target = " ".join(line) 130 | 131 | for idx, char in enumerate(self.input): 132 | target_char = target[idx] 133 | if target_char == char: 134 | stdscr.addstr(char, curses.color_pair(self.CORRECT_COLOR)) 135 | else: 136 | stdscr.addstr(target_char, curses.color_pair(self.INCORRECT_COLOR)) 137 | 138 | stdscr.addstr(target[len(self.input) : width - 1]) 139 | stdscr.addstr("\n" + self.input, curses.A_UNDERLINE) 140 | stdscr.refresh() 141 | 142 | def _10ff_display(self, stdscr, time_left): 143 | _height, width = stdscr.getmaxyx() 144 | width = min(width, MAX_WIDTH) 145 | stdscr.clear() 146 | 147 | wpm = self.calculate_wpm(self.game_time - time_left) 148 | stdscr.addstr("Time left: {:d}, WPM: {:d}\n".format(time_left, wpm)) 149 | 150 | # sets up initial lines 151 | if not self.current_line: 152 | self.current_line = self._get_line(self.next_words, width) 153 | cur_len = len(self.current_line) 154 | self.next_line = self._get_line(self.next_words[cur_len:], width) 155 | 156 | # if we finished the current line 157 | if self.offset >= len(self.current_line): 158 | self.current_line = self.next_line 159 | cur_len = len(self.current_line) 160 | self.next_line = self._get_line(self.next_words[cur_len:], width) 161 | self.offset = 0 162 | 163 | # color the words already typed on current line 164 | for i in range(self.offset): 165 | target = self.current_line[i] 166 | actual = self.typed[-(self.offset - i)] 167 | if actual == target: 168 | stdscr.addstr(target, curses.color_pair(self.CORRECT_COLOR)) 169 | else: 170 | stdscr.addstr(target, curses.color_pair(self.INCORRECT_COLOR)) 171 | 172 | stdscr.addstr(" ") 173 | 174 | stdscr.addstr(" ".join(self.current_line[self.offset :])) 175 | stdscr.addstr("\n" + " ".join(self.next_line)) 176 | stdscr.addstr("\n" + self.input, curses.A_UNDERLINE) 177 | stdscr.refresh() 178 | 179 | def _update_display(self, stdscr, time_left): 180 | if self.display == "progressive": 181 | self._progressive_display(stdscr, time_left) 182 | elif self.display == "10ff": 183 | self._10ff_display(stdscr, time_left) 184 | 185 | def _handle_key(self, key): 186 | char = curses.keyname(key).decode() 187 | if char == "^R": 188 | self.restart() 189 | if key in (curses.KEY_BACKSPACE, 127): 190 | self.input = self.input[:-1] 191 | elif chr(key) == " ": 192 | self._finish_word_event() 193 | else: 194 | self.input += chr(key) 195 | 196 | @staticmethod 197 | def _setup_ncurses(stdscr): 198 | # hide cursor 199 | curses.curs_set(0) 200 | 201 | # setup colors for printing text to screen 202 | curses.use_default_colors() 203 | curses.init_pair(Game.CORRECT_COLOR, curses.COLOR_GREEN, 0) 204 | curses.init_pair(Game.INCORRECT_COLOR, curses.COLOR_RED, 0) 205 | 206 | # don't wait for user input when calling getch()/getkey() 207 | stdscr.nodelay(True) 208 | 209 | # allow 100ms sleep on getch()/getkey() avoiding busy-wait 210 | # early returns when key is pressed, meaning no input delay 211 | stdscr.timeout(100) 212 | 213 | def _game_loop(self, stdscr): 214 | self._setup_ncurses(stdscr) 215 | self._update_display(stdscr, self.game_time) 216 | 217 | started = False 218 | start = time.time() 219 | time_left = self.game_time 220 | while time_left > 0: 221 | if not started: 222 | start = time.time() 223 | 224 | key = stdscr.getch() 225 | new_time_left = int(round(self.game_time - (time.time() - start))) 226 | if key == -1: 227 | # only update display when necessary 228 | if time_left != new_time_left: 229 | time_left = new_time_left 230 | self._update_display(stdscr, time_left) 231 | 232 | continue 233 | 234 | time_left = new_time_left 235 | started = True 236 | self._handle_key(key) 237 | self._update_display(stdscr, time_left) 238 | 239 | def print_stats(self): 240 | """Print ACC/CPM/WPM to console""" 241 | correct = len(self.correct) 242 | total = correct + len(self.incorrect) 243 | accuracy = correct / total * 100 244 | print("ACC: {:.2f}%".format(accuracy)) 245 | cpm = self.calculate_cpm(self.game_time) 246 | print("CPM: {:d}".format(cpm)) 247 | wpm = self.calculate_wpm(self.game_time) 248 | print("WPM: {:d}".format(wpm)) 249 | 250 | def restart(self): 251 | """ 252 | Reset the Game class, effective starting a new game 253 | with new words, but based on same configuration. 254 | """ 255 | self.input = "" 256 | self.correct = [] 257 | self.incorrect = [] 258 | self.typed = [] 259 | self.next_words = [self._get_word() for _ in range(QUEUE_SIZE)] 260 | 261 | if self.display == "10ff": 262 | self.offset = 0 263 | self.current_line = [] 264 | self.next_line = [] 265 | 266 | self.play() 267 | 268 | def play(self): 269 | """Start typing game and print results to terminal""" 270 | curses.wrapper(self._game_loop) 271 | self.print_stats() 272 | 273 | 274 | def main(): 275 | """Parse arguments and start game based thereof""" 276 | parser = argparse.ArgumentParser( 277 | formatter_class=argparse.RawTextHelpFormatter, 278 | description=textwrap.dedent( 279 | """\ 280 | Start a minimal 10fastfingers-like typing game on the command line. 281 | 282 | Keybinds: 283 | CTRL+R: restart 284 | CTRL+C: exit 285 | """ 286 | ), 287 | ) 288 | parser.add_argument( 289 | "-v", 290 | "--vocab", 291 | type=str, 292 | metavar="vocab-file-path", 293 | default=VOCAB_PATH, 294 | help="path to newline separated vocab file", 295 | ) 296 | parser.add_argument( 297 | "-t", 298 | "--game_time", 299 | type=int, 300 | metavar="gametime-seconds", 301 | default=60, 302 | help="duration in seconds of the typing game", 303 | ) 304 | parser.add_argument( 305 | "-min", 306 | "--min_length", 307 | type=int, 308 | metavar="min-word-length", 309 | default=2, 310 | help="minimum word length", 311 | ) 312 | parser.add_argument( 313 | "-max", 314 | "--max_length", 315 | type=int, 316 | metavar="max-word-length", 317 | default=10, 318 | help="maximum word length", 319 | ) 320 | parser.add_argument( 321 | "-w", 322 | "--words", 323 | type=int, 324 | metavar="words-to-read", 325 | default=200, 326 | help="the amount of words to read from vocab - higher increases difficulty", 327 | ) 328 | parser.add_argument( 329 | "-a", 330 | "--advanced", 331 | action="store_const", 332 | const=1000, 333 | dest="words", 334 | help="use 1000 most common words (corresponds to 10ff advanced mode)", 335 | ) 336 | parser.add_argument( 337 | "-d", 338 | "--display", 339 | type=str, 340 | metavar="display", 341 | default="10ff", 342 | help="how to display words to type '10ff' or 'progressive'", 343 | ) 344 | args = parser.parse_args() 345 | game = Game(args) 346 | try: 347 | game.play() 348 | except KeyboardInterrupt: 349 | pass 350 | --------------------------------------------------------------------------------