Source code for mitype.app

"""This is the Mitype main app script."""

import curses
import os
import sys
import time
import webbrowser

import mitype.signals
from mitype.calculations import (
    accuracy,
    first_index_at_which_strings_differ,
    get_space_count_after_ith_word,
    number_of_lines_to_fit_text_in_window,
    speed_in_wpm,
    word_wrap,
)
from mitype.commandline import load_from_database, resolve_commandline_arguments
from mitype.history import save_history
from mitype.keycheck import (
    is_backspace,
    is_ctrl_backspace,
    is_ctrl_c,
    is_ctrl_t,
    is_enter,
    is_escape,
    is_left_arrow_key,
    is_resize,
    is_right_arrow_key,
    is_tab,
    is_valid_initial_key,
)
from mitype.timer import get_elapsed_minutes_since_first_keypress


[docs]class App: """Class for enclosing all methods required to run Mitype.""" def __init__(self): """Initialize the application class.""" # Start the parser self.text, self.text_id = resolve_commandline_arguments() self.tokens = self.text.split() # Squash multiple spaces, tabs, newlines to single space self.text = " ".join(self.tokens) self.text_backup = self.text # Current typed word and entire string self.current_word = "" self.current_string = "" self.key = "" # First valid key press self.first_key_pressed = False # Stores keypress, time tuple self.key_strokes = [] self.mistyped_keys = [] # Time at which test started self.start_time = 0 # Time at which test ended self.end_time = 0 # Keep track of the token index in text self.token_index = 0 # mode = 0 when in test # mode = 1 when in replay self.mode = 0 self.window_height = 0 self.window_width = 0 self.number_of_lines_to_print_text = 0 # Restrict current word length to a limit # Used to highlight once the limit is reached # limit is set to the length of largest word in string + 5 for buffer self.current_word_limit = len(max(self.tokens, key=len)) + 5 self.test_complete = False # Real-time speed, the value at the end of the test is the result # And a few other stats self.current_speed_wpm = 0 self.accuracy = 0 self.time_taken = 0 self.total_chars_typed = 0 # Color mapping self.Color = None sys.stdout = sys.__stdout__ # Set ESC delay to 0 (default 1 on linux) os.environ.setdefault("ESCDELAY", "0") # Start curses on main curses.wrapper(self.main)
[docs] def main(self, win): """Respond to user inputs. This is where the infinite loop is executed to continuously serve events. Args: win (any): Curses window object. """ # Initialize windows self.initialize(win) while True: # Typing mode key = self.keyinput(win) if not self.first_key_pressed: if is_escape(key) or is_ctrl_c(key): sys.exit(0) if is_left_arrow_key(key): self.switch_text(win, -1) if is_right_arrow_key(key): self.switch_text(win, 1) # Test mode if self.mode == 0: self.typing_mode(win, key) # Again mode else: # Tab to retry last test if is_tab(key): win.clear() self.reset_test() self.setup_print(win) self.update_state(win) # Replay if is_enter(key): self.replay(win) # Tweet result if is_ctrl_t(key): self.share_result() # Refresh for changes to show up on window win.refresh()
[docs] def initialize(self, win): """Configure the initial state of the curses interface. Args: win (any): Curses window. """ self.window_height, self.window_width = self.get_dimensions(win) # This works by adding extra spaces to the text where needed self.text = word_wrap(self.text, self.window_width) # Check if we can fit text in current window after adding word wrap self.screen_size_check() curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_GREEN) curses.init_pair(2, curses.COLOR_WHITE, curses.COLOR_RED) curses.init_pair(3, curses.COLOR_WHITE, curses.COLOR_BLUE) curses.init_pair(4, curses.COLOR_WHITE, curses.COLOR_YELLOW) curses.init_pair(5, curses.COLOR_WHITE, curses.COLOR_CYAN) curses.init_pair(6, curses.COLOR_WHITE, curses.COLOR_MAGENTA) curses.init_pair(7, curses.COLOR_BLACK, curses.COLOR_WHITE) class Color: """Color mapping.""" GREEN = curses.color_pair(1) RED = curses.color_pair(2) BLUE = curses.color_pair(3) YELLOW = curses.color_pair(4) CYAN = curses.color_pair(5) MAGENTA = curses.color_pair(6) BLACK = curses.color_pair(7) self.Color = Color # This sets input to be a non-blocking call and will block for 100ms # Returns -1 if no input found at the end of time win.nodelay(True) win.timeout(100) self.setup_print(win)
[docs] def setup_print(self, win): """Print setup text at beginning of each typing session. Args: win (any): Curses window object. """ win.addstr(0, 0, f" ID:{self.text_id} ", self.Color.CYAN) win.addstr(0, self.window_width // 2 - 4, " MITYPE ", self.Color.BLUE) # Text is printed BOLD initially # It is dimmed as user types on top of it win.addstr(2, 0, self.text, curses.A_BOLD) self.print_realtime_wpm(win) # Set cursor position to beginning of text win.move(2, 0)
[docs] def clear_line(self, win, line): """Clear a line on the window. Args: win (any): Curses window. line (int): Line number. """ # Cursor advances to next cell after the character is printed # This causes scroll with addstr on the last line which is disabled # Hence using insstr instead win.insstr(line, 0, " " * self.window_width)
[docs] def update_state(self, win): """Report on typing session results. Args: win (any): Curses window. """ self.clear_line(win, self.number_of_lines_to_print_text) self.clear_line(win, self.number_of_lines_to_print_text + 2) self.clear_line(win, self.number_of_lines_to_print_text + 4) # Highlight in RED if word reaches the word limit length if len(self.current_word) >= self.current_word_limit: win.addstr( self.number_of_lines_to_print_text, 0, self.current_word, self.Color.RED, ) else: win.addstr(self.number_of_lines_to_print_text, 0, self.current_word) # Text is printed BOLD initially # It is dimmed as user types on top of it win.addstr(2, 0, self.text, curses.A_BOLD) win.addstr(2, 0, self.text[0 : len(self.current_string)], curses.A_DIM) index = first_index_at_which_strings_differ(self.current_string, self.text) # Check if difference was found if index < len(self.current_string) <= len(self.text): self.mistyped_keys.append(len(self.current_string) - 1) win.addstr( 2 + index // self.window_width, index % self.window_width, self.text[index : len(self.current_string)], self.Color.RED, ) # End of test, all characters are typed out if index == len(self.text): self.test_end(win) win.refresh()
[docs] def test_end(self, win): """Trigger at the end of the test. Display options for the user to choose at the end of the test. Display stats. Args: win (any): Curses window. """ # Highlight mistyped characters for i in self.mistyped_keys: win.addstr( 2 + i // self.window_width, i % self.window_width, self.text[i], self.Color.RED, ) curses.curs_set(0) # Calculate stats at the end of the test if self.mode == 0: self.current_speed_wpm = speed_in_wpm(self.tokens, self.start_time) total_chars_in_text = len(self.text) wrongly_typed_chars = self.total_chars_typed - total_chars_in_text self.accuracy = accuracy(self.total_chars_typed, wrongly_typed_chars) self.time_taken = get_elapsed_minutes_since_first_keypress(self.start_time) self.mode = 1 # Find time difference between the key strokes # The key_strokes list is storing the time at which the key is pressed for index in range(len(self.key_strokes) - 1, 0, -1): self.key_strokes[index][0] -= self.key_strokes[index - 1][0] self.key_strokes[0][0] = 0 win.addstr(self.number_of_lines_to_print_text, 0, " Your typing speed is ") win.addstr(" " + self.current_speed_wpm + " ", self.Color.MAGENTA) win.addstr(" WPM ") win.addstr( self.number_of_lines_to_print_text + 2, 1, " Enter ", self.Color.BLACK, ) win.addstr(" to see replay, ") win.addstr(" Tab ", self.Color.BLACK) win.addstr(" to retry.") win.addstr( self.number_of_lines_to_print_text + 3, 1, " Arrow keys ", self.Color.BLACK, ) win.addstr(" to change text.") win.addstr( self.number_of_lines_to_print_text + 4, 1, " CTRL+T ", self.Color.BLACK, ) win.addstr(" to tweet result.") self.print_stats(win) self.first_key_pressed = False self.end_time = time.time() self.current_string = "" self.current_word = "" self.token_index = 0 self.start_time = 0 if not self.test_complete: win.refresh() save_history(self.text_id, self.current_speed_wpm, f"{self.accuracy:.2f}") self.test_complete = True
[docs] def typing_mode(self, win, key): """Start recording typing session progress. Args: win (any): Curses window. key (str): First typed character of the session. """ # Note start time when first valid key is pressed if not self.first_key_pressed and is_valid_initial_key(key): self.start_time = time.time() self.first_key_pressed = True if is_resize(key): self.resize(win) if not self.first_key_pressed: return self.key_strokes.append([time.time(), key]) self.print_realtime_wpm(win) self.key_printer(win, key)
[docs] @staticmethod def keyinput(win): """Retrieve next character of text input. Args: win (any): Curses window. Returns: str: Value of typed key. """ key = "" try: key = win.get_wch() if isinstance(key, int): if key in (curses.KEY_BACKSPACE, curses.KEY_DC): return "KEY_BACKSPACE" if key == curses.KEY_RESIZE: return "KEY_RESIZE" return key except curses.error: return ""
[docs] def key_printer(self, win, key): """Print required key to terminal. Args: win (any): Curses window object. key (str): Individual characters are returned as 1-character strings, and special keys such as function keys return longer strings containing a key name such as KEY_UP or ^G. """ # Reset test if is_escape(key): self.reset_test() elif is_ctrl_c(key): sys.exit(0) # Handle resizing elif is_resize(key): self.resize(win) # Check for backspace elif is_backspace(key): self.erase_key() elif is_ctrl_backspace(key): self.erase_word() # Ignore spaces at the start of the word (Plover support) elif key == " " and len(self.current_word) < self.current_word_limit: self.total_chars_typed += 1 if self.current_word != "": self.check_word() elif is_valid_initial_key(key): self.appendkey(key) self.total_chars_typed += 1 # Update state of window self.update_state(win)
[docs] def resize(self, win): """Respond to window resize events. Args: win (any): Curses window. """ win.clear() self.window_height, self.window_width = self.get_dimensions(win) self.text = word_wrap(self.text_backup, self.window_width) self.screen_size_check() self.print_realtime_wpm(win) self.setup_print(win) self.update_state(win)
[docs] def print_stats(self, win): """Print the bottom stats bar after each run. Args: win (any): Curses window. """ win.addstr( self.window_height - 1, 0, f" WPM: {self.current_speed_wpm} ", self.Color.MAGENTA, ) win.addstr( f" Time: {self.time_taken*60:.2f}s ", self.Color.GREEN, ) win.addstr( f" Accuracy: {self.accuracy:.2f}% ", self.Color.CYAN, )
[docs] def print_realtime_wpm(self, win): """Print realtime wpm during the test. Args: win (any): Curses window. """ current_wpm = 0 total_time = mitype.timer.get_elapsed_minutes_since_first_keypress( self.start_time, ) if total_time != 0: words = self.current_string.split() word_count = len(words) current_wpm = word_count / total_time win.addstr( 0, self.window_width - 14, f" {current_wpm:.2f} ", self.Color.CYAN, ) win.addstr(" WPM ")
[docs] def replay(self, win): """Play out a recording of the user's last session. Args: win (any): Curses window. """ win.clear() self.print_stats(win) win.addstr(self.number_of_lines_to_print_text + 2, 0, " " * self.window_width) curses.curs_set(1) win.addstr( 0, self.window_width - 14, " " + str(self.current_speed_wpm) + " ", self.Color.CYAN, ) win.addstr(" WPM ") self.setup_print(win) win.timeout(10) next_tick = time.time() for key in self.key_strokes: next_tick += key[0] wait_duration = max(0, next_tick - time.time()) time.sleep(wait_duration) _key = self.keyinput(win) if is_escape(_key) or is_ctrl_c(_key): sys.exit(0) self.key_printer(win, key[1]) win.timeout(100)
[docs] def share_result(self): """Open a twitter intent on a browser.""" message = ( f"My typing speed is {self.current_speed_wpm} WPM!" "Know yours on mitype.\n" "https://pypi.org/project/mitype/ by @MithilPoojary\n" "#TypingTest" ) # URL encode message message = message.replace("\n", "%0D").replace("#", "%23") url = "https://twitter.com/intent/tweet?text=" + message webbrowser.open(url, new=2)
[docs] def reset_test(self): """Reset the data for current typing session.""" self.mode = 0 self.current_word = "" self.current_string = "" self.first_key_pressed = False self.key_strokes = [] self.mistyped_keys = [] self.start_time = 0 self.token_index = 0 self.current_speed_wpm = 0 self.total_chars_typed = 0 self.accuracy = 0 self.time_taken = 0 self.test_complete = False curses.curs_set(1)
[docs] def switch_text(self, win, value): """Load next or previous text snippet from database. Args: win (any): Curses window. value (int): value to increase or decrement the text ID by. """ if isinstance(self.text_id, str): return win.clear() self.text_id += value self.text = load_from_database(self.text_id)[0] self.tokens = self.text.split() self.text = " ".join(self.tokens) self.text_backup = self.text self.text = word_wrap(self.text, self.window_width) self.reset_test() self.setup_print(win) self.update_state(win)
[docs] @staticmethod def get_dimensions(win): """Get the height and width of terminal. Args: win (any): Curses window object. Returns: (int, int): Tuple of height and width of terminal window. """ return win.getmaxyx()
[docs] def screen_size_check(self): """Check if screen size is enough to print text.""" self.number_of_lines_to_print_text = ( number_of_lines_to_fit_text_in_window(self.text, self.window_width) + 3 ) if self.number_of_lines_to_print_text + 7 >= self.window_height: curses.endwin() sys.stdout.write("Window too small to print given text") sys.exit(1)
[docs] def appendkey(self, key): """Append a character to the end of the current word. Args: key (key): Character to append. """ if len(self.current_word) < self.current_word_limit: self.current_word += key self.current_string += key
[docs] def erase_key(self): """Erase the last typed character.""" if len(self.current_word) > 0: self.current_word = self.current_word[:-1] self.current_string = self.current_string[:-1]
[docs] def erase_word(self): """Erase the last typed word.""" if len(self.current_word) > 0: index_word = self.current_word.rfind(" ") if index_word == -1: # Single word. word_length = len(self.current_speed_wpm) self.current_string = self.current_string[:-word_length] self.current_word = "" else: diff = len(self.current_word) - index_word self.current_word = self.current_word[:-diff] self.current_string = self.current_string[:-diff]
[docs] def check_word(self): """Accept finalized word.""" spc = get_space_count_after_ith_word(len(self.current_string), self.text) if self.current_word == self.tokens[self.token_index]: self.token_index += 1 self.current_word = "" self.current_string += spc * " " else: self.current_word += " " self.current_string += " "