├── LICENSE ├── README.md ├── classes.py ├── images ├── play.png └── tt.png ├── keys.json ├── requirements.txt └── termtype.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 bajaco 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | 21 | 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # termtype 2 | 3 | A Curses-based Python application to practice touch-typing by typing out random Wikipedia articles 4 | 5 | ![Screenshot](images/tt.png) 6 | 7 | ### Installation 8 | 9 | #### Windows-Specific 10 | *Note: This has only been tested on Linux. It should work on Mac, but Curses is not natively available in Windows. Try installing windows-curses first:* 11 | 12 | `pip install windows-curses` 13 | 14 | *I am interested in hearing how this runs on other platforms, so feel free to open an issue.* 15 | 16 | #### General 17 | 18 | 1. Optionally create and enter virtual environment: 19 | - `python -m venv venv` 20 | - `source venv/bin/activate` 21 | 2. Install required packages: `pip install -r requirements.txt` 22 | 3. Run application: `python termtype.py` 23 | 24 | Commands may differ, for example, pip3 instead of pip, python3 instead of python. 25 | 26 | ### Usage 27 | Select play from the menu to play the game. A random wikipedia article will be loaded for you to type. Any incorrect characters will be marked with an X. Additionally, above the Wikipedia text will be an indication of which finger should be used to type that particular key: 28 | 1. Left pinky 29 | 2. Left ring 30 | 3. Left middle 31 | 4. Left index 32 | 5. Right index 33 | 6. Right middle 34 | 7. Right ring 35 | 8. Right pinky 36 | 37 | ![Screenshot](images/play.png) 38 | 39 | Press enter after every line, every sentence, or whenever you prefer to clear words on the screen. After finishing the text you will be brought to a statistics screen, which is also accessible through the main menu. You can also press escape at any time to quit typing, the words you've entered will still be counted towards your statistics. 40 | 41 | ### Notes 42 | - I chose not to use Curses to render characters directly, and instead render the entire page at once. This was to ensure that the terminal can be resized without adversely affecting gameplay. This means that the text automatically word wraps to display properly. 43 | 44 | ### Project goals moving forward 45 | Having gotten a couple collaborators for certain issues I wanted to outline some potential plans for Termtype moving forward. New plans or ideas will be added here as the project progresses. 46 | 47 | - Different modes for finger indication. Potential to use letters (IMRP) for corresponding fingers. Also potentially a color-corresponding mode. 48 | 49 | - Package the project for PyPI 50 | 51 | - More involved statistics display 52 | -------------------------------------------------------------------------------- /classes.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | import json 3 | import curses 4 | import wikipedia 5 | import string 6 | import time 7 | from datetime import timedelta 8 | import sqlite3 9 | import pathlib 10 | 11 | class Database: 12 | def __init__(self,stdscr): 13 | self.path = pathlib.Path(__file__).parent.absolute() / 'termtype.db' 14 | self.results = None 15 | self.stdscr = stdscr 16 | self.conn = sqlite3.connect(self.path) 17 | self.c = self.conn.cursor() 18 | try: 19 | self.c.execute('''CREATE TABLE sessions 20 | (duration real, words int, errors int, time int)''') 21 | self.conn.commit() 22 | 23 | #If table exists do nothing 24 | except sqlite3.Error: 25 | pass 26 | 27 | def write(self, duration, words, errors): 28 | insert = (duration, words, errors, time.time(),) 29 | self.c.execute('INSERT INTO sessions VALUES (?,?,?,?)', insert) 30 | self.conn.commit() 31 | 32 | def read(self): 33 | self.c.execute('SELECT * FROM sessions') 34 | self.results = self.c.fetchall() 35 | 36 | def get_result_total(self): 37 | average = [0] * 3 38 | for result in self.results: 39 | average[0] += result[0] 40 | average[1] += result[1] 41 | average[2] += result[2] 42 | return average 43 | 44 | def get_result_7_days(self): 45 | average = [0] * 3 46 | for result in self.results: 47 | if time.time() - result[3] <= timedelta(days=7).total_seconds(): 48 | average[0] += result[0] 49 | average[1] += result[1] 50 | average[2] += result[2] 51 | return average 52 | 53 | 54 | def get_result_last(self): 55 | if len(self.results) > 0: 56 | return self.results[-1] 57 | else: 58 | return self.results[0] 59 | 60 | def get_pretty_duration(self,dur): 61 | durstring = '' 62 | if dur > 3600: 63 | hours = int(dur/3600) 64 | durstring += str(hours) 65 | durstring += ' hours ' 66 | dur -= 3600 * hours 67 | 68 | if dur > 60: 69 | minutes = int(dur/60) 70 | durstring += str(minutes) 71 | durstring += ' minutes ' 72 | dur -= 60 * minutes 73 | 74 | durstring += str(int(dur)) 75 | durstring += ' seconds' 76 | return durstring 77 | 78 | 79 | def print_stats(self): 80 | stats = None 81 | if self.results: 82 | last = self.get_result_last() 83 | total = self.get_result_total() 84 | seven = self.get_result_7_days() 85 | stats = ['Most Recent Session:', 86 | ' Words per Minute: ' + str(round(last[1]/last[0]*60,1)), 87 | ' Errors per Minute: ' + str(round(last[2]/last[0]*60,1)), 88 | ' Words: ' + str(last[1]), 89 | ' Errors: ' + str(last[2]), 90 | ' Time: ' + self.get_pretty_duration(last[0]), 91 | '', 92 | 'Last Seven Days:', 93 | ' Words per Minute: ' + str(round(seven[1]/seven[0]*60,1)), 94 | ' Errors per Minute: ' + str(round(seven[2]/seven[0]*60,1)), 95 | ' Words: ' + str(seven[1]), 96 | ' Errors: ' + str(seven[2]), 97 | ' Time: ' + self.get_pretty_duration(seven[0]), 98 | '', 99 | 'All Time:', 100 | ' Words per Minute: ' + str(round(total[1]/total[0]*60,1)), 101 | ' Errors per Minute: ' + str(round(total[2]/total[0]*60,1)), 102 | ' Words: ' + str(total[1]), 103 | ' Errors: ' + str(total[2]), 104 | ' Time: ' + self.get_pretty_duration(total[0]), 105 | '', 106 | 'Press q to quit, any other key to continue:' 107 | ] 108 | else: 109 | stats=['No Stats to Display!','','Press q to quit, any other key to continue'] 110 | for i,line in enumerate(stats): 111 | if i < self.stdscr.getmaxyx()[0]: 112 | self.stdscr.addstr(i,0,line) 113 | 114 | class Timer: 115 | def __init__(self): 116 | self.beginning = None 117 | self.end = None 118 | self.timing = False 119 | self.duration = None 120 | 121 | def start(self): 122 | self.beginning = time.time() 123 | self.timing = True 124 | 125 | def stop(self): 126 | self.end = time.time() 127 | self.timing = False 128 | self.duration = self.end - self.beginning 129 | 130 | def is_timing(self): 131 | return self.timing 132 | 133 | def get_duration(self): 134 | return self.duration 135 | 136 | 137 | 138 | class Keyboard: 139 | def __init__(self,stdscr): 140 | self.KEYS = None 141 | try: 142 | with open('keys.json', 'r') as f: 143 | self.KEYS = json.load(f) 144 | except OSError: 145 | print('Could not load keys.json!') 146 | 147 | def get_finger(self, key): 148 | return self.KEYS[key] 149 | 150 | def has(self, c): 151 | return c.lower() in self.KEYS 152 | 153 | def transform_text(self, text): 154 | newtext = [] 155 | for c in text: 156 | if c.lower() in self.KEYS: 157 | newtext.append(self.KEYS[c.lower()]) 158 | else: 159 | newtext.append(c) 160 | return ''.join(newtext) 161 | 162 | def error_text(self, master, typed): 163 | new_text = [] 164 | for i,c in enumerate(typed): 165 | if c == master[i]: 166 | if c == ' ': 167 | new_text.append(' ') 168 | else: 169 | new_text.append('_') 170 | else: 171 | new_text.append('X') 172 | 173 | return ''.join(new_text) 174 | 175 | 176 | 177 | #string buffer class for editing strings 178 | class Buffer: 179 | def __init__(self,initial_text = ''): 180 | self.text = initial_text 181 | self.length = len(self.text) 182 | self.position = len(self.text) 183 | 184 | def __left(self): 185 | if self.position > 0: 186 | self.position -= 1 187 | 188 | def __right(self): 189 | if self.position < self.length: 190 | self.position += 1 191 | 192 | def __delete(self): 193 | if self.length > self.position: 194 | self.text = self.text[:self.position] + self.text[self.position + 1:] 195 | self.length -= 1 196 | 197 | def insert(self, insert_string): 198 | self.text = (self.text[0:self.position] + insert_string + self.text[self.position:self.length]) 199 | self.length += len(insert_string) 200 | self.position += len(insert_string) 201 | 202 | def get_text(self): 203 | return self.text 204 | 205 | def input(self, c, keyboard): 206 | if keyboard.has(chr(c)): 207 | self.insert(chr(c)) 208 | else: 209 | if c == curses.KEY_BACKSPACE: 210 | self.__left() 211 | self.__delete() 212 | 213 | def clear(self): 214 | self.text = '' 215 | self.length = 0 216 | self.position = 0 217 | 218 | def new_errors(self,comparison_text): 219 | errors = 0 220 | for i,c in enumerate(self.text): 221 | if i < len(comparison_text): 222 | if c != comparison_text[i]: 223 | errors += 1 224 | return errors 225 | 226 | 227 | def get_count(self): 228 | return len(self.text.split()) 229 | 230 | #formatter class for putting text in window 231 | class Formatter: 232 | 233 | def __init__(self, stdscr, text, line_height=1, 234 | vertical_offset=0, vertical_buffer=0): 235 | self.master_text = text 236 | self.text = self.master_text 237 | self.words = text.split() 238 | self.lines = [] 239 | self.pages = [] 240 | self.line_height = line_height 241 | self.vertical_offset = vertical_offset 242 | self.vertical_buffer = vertical_buffer 243 | self.stdscr = stdscr 244 | 245 | def set_master(self,text): 246 | self.master_text = text 247 | 248 | def reset(self): 249 | self.text = self.master_text 250 | self.words = self.text.split() 251 | self.lines = [] 252 | self.pages = [] 253 | 254 | def remove_words(self, index): 255 | new_text = [] 256 | words = self.text.split() 257 | removed_words = words[:index] 258 | words = words[index:] 259 | self.master_text = ' '.join(words) 260 | return ' '.join(removed_words) 261 | 262 | def out_of_words(self): 263 | if len(self.pages) > 0: 264 | return False 265 | return True 266 | 267 | 268 | def make_line(self): 269 | line = [] 270 | line_length = 0 271 | for word in self.words: 272 | line_length += len(word) + 1 273 | if line_length < (self.stdscr.getmaxyx()[1] / 2): 274 | line.append(word) 275 | else: 276 | break 277 | self.lines.append(line) 278 | self.words = self.words[len(line):] 279 | 280 | def make_all_lines(self): 281 | while len(self.words) > 0: 282 | self.make_line() 283 | 284 | def make_page(self): 285 | index = int(self.stdscr.getmaxyx()[0] / self.line_height 286 | - self.vertical_buffer) 287 | self.pages.append(self.lines[0:index]) 288 | self.lines = self.lines[index:] 289 | 290 | def make_all_pages(self): 291 | while len(self.lines) > 0: 292 | self.make_page() 293 | 294 | def dump_text(self): 295 | text = [] 296 | for page in self.pages: 297 | for line in page: 298 | for word in line: 299 | text.append(word) 300 | return ' '.join(text) 301 | 302 | #convert text into pages of lines that fit the terminal window 303 | #and then display the first page. The first page will be destroyed 304 | #when conifrmed as a matching entry 305 | def print_text(self): 306 | self.reset() 307 | self.words = self.text.split() 308 | self.make_all_lines() 309 | self.make_all_pages() 310 | if len(self.pages) > 0: 311 | page = self.pages[0] 312 | for i, line in enumerate(page): 313 | text = ' '.join(line) 314 | text = list(text) 315 | text = ' '.join(text) 316 | self.stdscr.addstr(self.vertical_offset + 317 | (self.line_height * i),0,text) 318 | 319 | 320 | class Menu: 321 | def __init__(self, stdscr, *args): 322 | self.menu_items = args 323 | self.menu_length = len(self.menu_items) 324 | self.stdscr = stdscr 325 | self.active_item = 1 326 | 327 | def print_splash(self): 328 | splash = ''' 329 | Welcome to . . . 330 | | | 331 | __| _ \ __| __ `__ \ __| | | __ \ _ \ 332 | | __/ | | | | | | | | | __/ 333 | \__| \___| _| _| _| _| \__| \__, | .__/ \___| 334 | ____/ _| 335 | ''' 336 | self.stdscr.addstr(splash) 337 | 338 | def print_menu(self): 339 | for i,v in enumerate(self.menu_items): 340 | if i + 1 == self.active_item: 341 | self.stdscr.addstr(i + 8, 0, 342 | str(i + 1) + '. ' + v, curses.A_STANDOUT) 343 | else: 344 | self.stdscr.addstr(i + 8, 0, str(i + 1) + '. ' + v) 345 | 346 | def navigate(self,key): 347 | 348 | if key == curses.KEY_UP and self.active_item > 1: 349 | self.active_item -= 1 350 | elif key == curses.KEY_DOWN and self.active_item < self.menu_length: 351 | self.active_item += 1 352 | elif key == curses.KEY_ENTER or key == 10: 353 | return self.active_item 354 | elif key in [ 49,50,51,52 ]: 355 | # ASCII code for 1,2,3,4 are 49,50,51,52 respectively 356 | self.active_item = key - 48 357 | return self.active_item 358 | return 0 359 | 360 | class Wiki: 361 | 362 | def __init__(self): 363 | self.page = None 364 | while self.page is None: 365 | try: 366 | title = wikipedia.random() 367 | self.page = wikipedia.page(title=title) 368 | except: 369 | pass 370 | 371 | 372 | def get_page(self): 373 | printable = set(string.printable) 374 | content = self.page.content 375 | 376 | #remove characters that cannot be displayed 377 | content = ''.join(filter(lambda x: x in printable, content)) 378 | 379 | #remove references and other errata 380 | #content = content.split('==') 381 | #content = content[0] 382 | 383 | return content 384 | 385 | -------------------------------------------------------------------------------- /images/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bajaco/termtype/6982bce62a4502a9ab08a4a9f8215eea1a629c2b/images/play.png -------------------------------------------------------------------------------- /images/tt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bajaco/termtype/6982bce62a4502a9ab08a4a9f8215eea1a629c2b/images/tt.png -------------------------------------------------------------------------------- /keys.json: -------------------------------------------------------------------------------- 1 | { 2 | "~": "1", 3 | "`": "1", 4 | "!": "1", 5 | "1": "1", 6 | "q": "1", 7 | "a": "1", 8 | "z": "1", 9 | "@": "2", 10 | "2": "2", 11 | "w": "2", 12 | "s": "2", 13 | "x": "2", 14 | "#": "3", 15 | "3": "3", 16 | "e": "3", 17 | "d": "3", 18 | "c": "3", 19 | "$": "4", 20 | "4": "4", 21 | "%": "4", 22 | "5": "4", 23 | "r": "4", 24 | "t": "4", 25 | "f": "4", 26 | "g": "4", 27 | "v": "4", 28 | "b": "4", 29 | "^": "4", 30 | "6": "4", 31 | "&": "5", 32 | "7": "5", 33 | "y": "5", 34 | "u": "5", 35 | "h": "5", 36 | "j": "5", 37 | "n": "5", 38 | "m": "5", 39 | "*": "6", 40 | "8": "6", 41 | "i": "6", 42 | "k": "6", 43 | "<": "6", 44 | ",": "6", 45 | "(": "7", 46 | "9": "7", 47 | "o": "7", 48 | "l": "7", 49 | ">": "7", 50 | ".": "7", 51 | ")": "8", 52 | "0": "8", 53 | "_": "8", 54 | "-": "8", 55 | "+": "8", 56 | "=": "8", 57 | "p": "8", 58 | "{": "8", 59 | "[": "8", 60 | "}": "8", 61 | "]": "8", 62 | "|": "8", 63 | "\\": "8", 64 | ":": "8", 65 | ";": "8", 66 | "\"": "8", 67 | "'": "8", 68 | "?": "8", 69 | "/": "8", 70 | " ": " " 71 | } 72 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | beautifulsoup4==4.9.3 2 | certifi==2020.6.20 3 | chardet==3.0.4 4 | idna==2.10 5 | requests==2.24.0 6 | soupsieve==2.0.1 7 | urllib3==1.25.10 8 | wikipedia==1.4.0 9 | -------------------------------------------------------------------------------- /termtype.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import curses 4 | from classes import Keyboard 5 | from classes import Menu 6 | from classes import Buffer 7 | from classes import Formatter 8 | from classes import Wiki 9 | from classes import Timer 10 | from classes import Database 11 | 12 | 13 | def main(stdscr): 14 | 15 | #initialization 16 | menu = Menu(stdscr, 'Help', 'Play', 'Stats', 'Exit') 17 | keyboard = Keyboard(stdscr) 18 | database = Database(stdscr) 19 | mode = 0 20 | key = 0 21 | curses.curs_set(0) 22 | curses.noecho() 23 | curses.use_default_colors() 24 | 25 | #selct mode based on menu options 26 | while mode != 4: 27 | 28 | #menu mode 29 | if mode == 0: 30 | stdscr.clear() 31 | menu.print_splash() 32 | menu.print_menu() 33 | mode = menu.navigate(stdscr.getch()) 34 | stdscr.refresh() 35 | 36 | #help mode 37 | elif mode == 1: 38 | help_string = ''' 39 | Welcome to termtype! Termtype is a touch-typing aid 40 | that allows you to practice your skills by typing 41 | random Wikipedia articles. Simply type the displayed 42 | text, and refer to the line above to check which finger 43 | to use. From left pinky to right pinky, the fingers 44 | are numbered 1-8, with thumbs omitted. 45 | 46 | Press enter to remove typed words from the display, 47 | and press escape at any time to stop typing. Your 48 | typed words will still count towards your progress 49 | even if you don't finish the article, so feel free 50 | to stop at anytime! 51 | 52 | For more information, visit https://github.com/bajaco/termtype 53 | 54 | Press any key to continue: 55 | ''' 56 | help_formatter = Formatter(stdscr, help_string) 57 | stdscr.clear() 58 | help_formatter.print_text() 59 | c = stdscr.getch() 60 | 61 | mode = 0 62 | 63 | #play mode 64 | elif mode == 2: 65 | 66 | #initialization for play mode 67 | wiki = Wiki() 68 | typing_buffer = Buffer() 69 | page_text = wiki.get_page() 70 | guide_text = keyboard.transform_text(page_text) 71 | error_text = '' 72 | errors = 0 73 | entered_words = 0 74 | timer = Timer() 75 | 76 | #Formatter for article 77 | wiki_formatter = Formatter(stdscr, page_text, 78 | line_height=6, vertical_offset=1, vertical_buffer=0) 79 | 80 | #Formatter for finger indication 81 | guide_formatter = Formatter(stdscr, guide_text, 82 | line_height=6, vertical_offset=0, vertical_buffer=0) 83 | 84 | #Formatter for typed text 85 | typing_formatter = Formatter(stdscr, typing_buffer.get_text(), 86 | line_height=6, vertical_offset=2, vertical_buffer=0) 87 | 88 | #Formatter for error text 89 | error_formatter = Formatter(stdscr, error_text, 90 | line_height=6, vertical_offset=3, vertical_buffer=0) 91 | 92 | #gameplay loop 93 | while(True): 94 | #clear string and print from formatters 95 | stdscr.clear() 96 | wiki_formatter.print_text() 97 | guide_formatter.print_text() 98 | typing_formatter.print_text() 99 | error_formatter.print_text() 100 | 101 | #break if there are no more words to be typed and show statistics 102 | if wiki_formatter.out_of_words(): 103 | timer.stop() 104 | break 105 | 106 | #if key is enter, remove page from wiki and guide formatters 107 | stdscr.refresh() 108 | c = stdscr.getch() 109 | 110 | #if key is ENTER 111 | if c == 10: 112 | count = typing_buffer.get_count() 113 | entered_words += count 114 | removed = wiki_formatter.remove_words(count) 115 | guide_formatter.remove_words(count) 116 | errors += typing_buffer.new_errors(removed) 117 | typing_buffer.clear() 118 | error_formatter.set_master('') 119 | typing_formatter.set_master('') 120 | 121 | #if key is ESC 122 | elif c == 27: 123 | count = typing_buffer.get_count() 124 | entered_words += count 125 | if entered_words == 0: 126 | break 127 | timer.stop() 128 | removed = wiki_formatter.remove_words(count) 129 | guide_formatter.remove_words(count) 130 | errors += typing_buffer.new_errors(removed) 131 | break 132 | 133 | #other keys 134 | else: 135 | #start timing if necessary 136 | if not timer.is_timing(): 137 | timer.start() 138 | 139 | typing_buffer.input(c, keyboard) 140 | typing_formatter.set_master(typing_buffer.get_text()) 141 | error_formatter.set_master(keyboard.error_text( 142 | wiki_formatter.dump_text(), typing_buffer.get_text())) 143 | 144 | #statistics loop 145 | while(True): 146 | if entered_words == 0: 147 | mode = 0 148 | break 149 | stdscr.clear() 150 | database.write(timer.get_duration(), entered_words, errors) 151 | database.read() 152 | database.print_stats() 153 | stdscr.refresh() 154 | 155 | 156 | c = stdscr.getkey() 157 | if c == 'q': 158 | mode = 4 159 | break 160 | else: 161 | mode = 0 162 | break 163 | #stats mode 164 | elif mode == 3: 165 | 166 | while(True): 167 | stdscr.clear() 168 | database.read() 169 | database.print_stats() 170 | stdscr.refresh() 171 | c = stdscr.getkey() 172 | if c == 'q': 173 | mode = 4 174 | break 175 | else: 176 | mode = 0 177 | break 178 | 179 | curses.wrapper(main) 180 | --------------------------------------------------------------------------------