# -*- encoding: utf-8 -*- """ This file is part of the wpm software. Copyright 2017, 2018 Christian Stigen Larsen Distributed under the GNU Affero General Public License (AGPL) v3 or later. See the file LICENSE.txt for the full license text. This software makes use of open source software. The quotes database is *not* covered by the AGPL! """ import curses import curses.ascii import locale import os import sys from wpm.config import Config from wpm.convert import wpm_to_cpm from wpm.error import WpmError from wpm.gauss import confidence_interval, prediction_interval from wpm.histogram import histogram, plot import wpm.devfeature as devfeature class Screen(object): """Renders the terminal screen.""" COLOR_AUTHOR = 1 COLOR_BACKGROUND = 2 COLOR_CORRECT = 3 COLOR_HISCORE = 4 COLOR_INCORRECT = 5 COLOR_PROMPT = 6 COLOR_QUOTE = 7 COLOR_STATUS = 8 def __init__(self, monochrome): self.config = Config() self.monochrome = monochrome # Make delay slower os.environ.setdefault("ESCDELAY", self.config.curses.escdelay) # Use the preferred system encoding locale.setlocale(locale.LC_ALL, "") self.encoding = locale.getpreferredencoding().lower() self.screen = curses.initscr() self.screen.nodelay(True) # Flag controls whether we should redraw the screen or not. This is # used to reduce CPU usage in the browsing screen. self.redraw = True min_lines = 12 if self.lines < min_lines: curses.endwin() raise WpmError( "wpm requires at least %d lines in your display" % min_lines) min_cols = 20 if self.columns < min_cols: curses.endwin() raise WpmError( "wpm requires at least %d columns in your display" % min_cols) try: self.screen.keypad(True) curses.noecho() curses.cbreak() curses.start_color() self.set_colors() self.window = curses.newwin(self.lines, self.columns, 0, 0) self.window.keypad(True) self.window.timeout(self.config.curses.window_timeout) self.window.bkgd(" ", Screen.COLOR_BACKGROUND) # Local variables related to quote. TODO: Move this mess to somewhere # else. self.cheight = 0 self.first_key = True self.quote = "" self.quote_author = "" self.quote_columns = 0 self.quote_coords = tuple() self.quote_height = 0 self.quote_id = 0 self.quote_lengths = tuple() self.quote_title = "" except: curses.endwin() raise @staticmethod def _word_wrap(text, width): """Returns lengths of lines that can be printed without wrapping.""" lengths = [] while len(text) > width: try: end = text[:width + 1].rindex(" ") except ValueError: break # We can't divide the input nicely, so just display it as-is if end == -1: return [len(text)] lengths.append(end) text = text[end + 1:] if text: lengths.append(len(text)) return lengths @staticmethod def _screen_coords(lengths, position): """Translates quote offset into screen coordinates. Args: lengths: List of line lengths for the word-wrapped quote. position: Offset into the quote that we want to translate to screen coordinates. Returns: Tuple containing X and Y screen coordinates. """ y_position = 0 for y_position, line_length in enumerate(lengths): if position <= line_length: break position -= line_length + 1 return position, y_position @property def columns(self): """Returns number of terminal columns.""" # pylint: disable=no-member return curses.COLS @property def lines(self): """Returns number of terminal lines.""" # pylint: disable=no-member return curses.LINES def set_colors(self): """Sets up curses color pairs.""" hicolor = os.getenv("TERM").endswith("256color") if self.monochrome: color = self.config.monochromecolors elif hicolor: color = self.config.xterm256colors else: color = self.config.xtermcolors bg = color.background curses.init_pair(Screen.COLOR_AUTHOR, *color.author) curses.init_pair(Screen.COLOR_BACKGROUND, bg, bg) curses.init_pair(Screen.COLOR_CORRECT, *color.correct) curses.init_pair(Screen.COLOR_HISCORE, *color.score) curses.init_pair(Screen.COLOR_INCORRECT, *color.incorrect) curses.init_pair(Screen.COLOR_PROMPT, *color.prompt) curses.init_pair(Screen.COLOR_QUOTE, *color.quote) curses.init_pair(Screen.COLOR_STATUS, *color.top_bar) # Rebind class variables Screen.COLOR_AUTHOR = curses.color_pair(Screen.COLOR_AUTHOR) Screen.COLOR_BACKGROUND = curses.color_pair(Screen.COLOR_BACKGROUND) Screen.COLOR_CORRECT = curses.color_pair(Screen.COLOR_CORRECT) Screen.COLOR_HISCORE = curses.color_pair(Screen.COLOR_HISCORE) Screen.COLOR_INCORRECT = curses.color_pair(Screen.COLOR_INCORRECT) Screen.COLOR_PROMPT = curses.color_pair(Screen.COLOR_PROMPT) Screen.COLOR_QUOTE = curses.color_pair(Screen.COLOR_QUOTE) Screen.COLOR_STATUS = curses.color_pair(Screen.COLOR_STATUS) if not hicolor: # Make certain colors more visible Screen.COLOR_CORRECT |= curses.A_DIM Screen.COLOR_INCORRECT |= curses.A_UNDERLINE | curses.A_BOLD Screen.COLOR_QUOTE |= curses.A_BOLD Screen.COLOR_STATUS |= curses.A_BOLD @staticmethod def is_escape(key): """Checks for escape key.""" if len(key) == 1: return ord(key) == curses.ascii.ESC return False @staticmethod def is_backspace(key): """Checks for backspace key.""" if len(key) > 1: return key == "KEY_BACKSPACE" if ord(key) in (curses.ascii.BS, curses.ascii.DEL): return True return False def get_key(self): """Gets a single key stroke.""" # pylint: disable=method-hidden # Install a suitable get_key based on Python version if sys.version_info[0:2] >= (3, 3): self.get_key = self._get_key_py33 else: self.get_key = self._get_key_py27 return self.get_key() def _get_key_py33(self): """Python 3.3+ implementation of get_key.""" # pylint: disable=too-many-return-statements try: # Curses in Python 3.3 handles unicode via get_wch key = self.window.get_wch() if isinstance(key, int): if key == curses.KEY_BACKSPACE: return "KEY_BACKSPACE" if key == curses.KEY_LEFT: return "KEY_LEFT" if key == curses.KEY_RIGHT: return "KEY_RIGHT" if key == curses.KEY_RESIZE: return "KEY_RESIZE" return None return key except curses.error: return None except KeyboardInterrupt: raise def _get_key_py27(self): """Python 2.7 implementation of get_key.""" # pylint: disable=too-many-return-statements try: key = self.window.getkey() # Start of UTF-8 multi-byte character? if self.encoding == "utf-8" and ord(key[0]) & 0x80: multibyte = key[0] cont_bytes = ord(key[0]) << 1 while cont_bytes & 0x80: cont_bytes <<= 1 multibyte += self.window.getkey()[0] return multibyte.decode(self.encoding) if isinstance(key, int): if key == curses.KEY_BACKSPACE: return "KEY_BACKSPACE" if key == curses.KEY_LEFT: return "KEY_LEFT" if key == curses.KEY_RIGHT: return "KEY_RIGHT" if key == curses.KEY_RESIZE: return "KEY_RESIZE" return None return key.decode("ascii") except KeyboardInterrupt: raise except curses.error: return None def addstr(self, x_pos, y_pos, text, color=None): """Wraps call around curses.window.addsr.""" if self.lines > y_pos >= 0: if x_pos >= 0 and (x_pos + len(text)) < self.columns: self.window.addstr(y_pos, x_pos, text, color) def addstr_u8(self, x_pos, y_pos, text, color=None): """Wraps call around curses.window.addsr.""" if self.lines > y_pos >= 0: if x_pos >= 0 and (x_pos + len(text)) < self.columns: self.window.addstr(y_pos, x_pos, text.encode(self.encoding), color) def chgat(self, x_pos, y_pos, length, color): """Wraps call around curses.window.chgat.""" if self.lines > y_pos >= 0: if x_pos >= 0 and (x_pos + length) <= self.columns: self.window.chgat(y_pos, x_pos, length, color) def set_cursor(self, x_pos, y_pos): """Sets cursor position.""" if (y_pos < self.lines) and (x_pos < self.columns): self.window.move(y_pos, x_pos) def right_column(self, y_pos, x_pos, width, text): """Writes text to screen in coumns.""" lengths = Screen._word_wrap(text, width) for cur_y, length in enumerate(lengths, y_pos): self.addstr(x_pos - length, cur_y, text[:length].encode(self.encoding), Screen.COLOR_AUTHOR) text = text[1+length:] return len(lengths) def update_quote(self, color): """Renders complete quote on screen.""" quote = self.quote[:] for y_pos, length in enumerate(self.quote_lengths, 2): self.addstr(0, y_pos, quote[:length].encode(self.encoding), color) quote = quote[1 + length:] def update_author(self): """Renders author on screen.""" author = u"— %s, %s" % (self.quote_author, self.quote_title) self.cheight = 4 + self.quote_height self.cheight += self.right_column(self.cheight - 1, self.quote_columns - 10, self.quote_columns // 2, author) def update_header(self, text): """Renders top-bar header.""" self.addstr(0, 0, text, Screen.COLOR_STATUS) self.chgat(0, 0, self.columns, Screen.COLOR_STATUS) #self.window.chgat(0, 0, self.columns, Screen.COLOR_STATUS) def set_quote(self, quote): """Sets up variables used for a new quote.""" # TODO: Move this stuff elsewhere if self.config.wpm.wrap_width > 0: self.quote_columns = min(self.columns, self.config.wpm.wrap_width) else: self.quote_columns = self.columns self.cheight = 0 self.quote = quote.text self.quote_author = quote.author self.quote_id = quote.text_id self.quote_title = quote.title self.quote_lengths = tuple(Screen._word_wrap(self.quote, self.quote_columns - 1)) self.quote_height = len(self.quote_lengths) # Remember (x, y) position for each quote offset. self.quote_coords = [] for offset in range(len(self.quote)+1): x_pos, y_pos = Screen._screen_coords(self.quote_lengths, offset) self.quote_coords.append((x_pos, y_pos)) self.quote_coords = tuple(self.quote_coords) def clear_prompt(self): self.set_cursor(0, self.cheight) self.window.clrtoeol() def update_prompt(self, prompt): """Prints prompt on the display.""" self.addstr(0, self.cheight, (prompt + " ").encode(self.encoding), Screen.COLOR_PROMPT) def show_browser(self, head, stats, cpm_flag): """Show quote browsing screen.""" if not self.redraw: return self.update_header(head) self.update_quote(Screen.COLOR_QUOTE) self.update_author() self.show_help() self.show_stats(stats, cpm_flag) self.set_cursor(0, 2) self.redraw = False def show_histogram(self, stats): results = stats.text_id_results(stats.tag, self.quote_id) wpms = [x.wpm for x in results.results] cols = self.columns // 4 low, width, histo = histogram(wpms, cols) line = "".join(plot(cols, low, width, histo)) self.cheight += 2 xpos = ((self.columns - len(line)) // 2) - 1 color = Screen.COLOR_PROMPT self.addstr(xpos - 6, self.cheight, "%5.1f" % min(wpms), color) self.addstr(xpos + len(line) + 1, self.cheight, "%5.1f" % max(wpms), color) self.addstr_u8(xpos, self.cheight, line, color) def show_help(self): """Shows help instructions on screen.""" self.cheight += 1 self.set_cursor(0, self.cheight) self.addstr(0, self.cheight, "Start typing, hit SPACE/ARROWS to browse or ESC to quit.", Screen.COLOR_PROMPT) def show_stats(self, stats, cpm_flag): """Shows statistics for the current quote.""" results = stats.text_id_results(stats.tag, self.quote_id) if len(results) < 2: return percent = self.config.wpm.confidence_level assert 0.0 <= percent <= 1.0 alpha = 1.0 - percent samples = len(results) wpm_avg, acc_avg = results.averages() wpm_sd, acc_sd = results.stddevs() wpm_min, wpm_max, acc_min, acc_max = results.extremals() wpm_ci0, wpm_ci1 = confidence_interval(wpm_avg, wpm_sd, samples, alpha) wpm_pi0, wpm_pi1 = prediction_interval(wpm_avg, wpm_sd, alpha) acc_ci0, acc_ci1 = confidence_interval(acc_avg, acc_sd, samples, alpha) acc_pi0, acc_pi1 = prediction_interval(acc_avg, acc_sd, alpha) if cpm_flag: wpm_avg = wpm_to_cpm(wpm_avg) wpm_sd = wpm_to_cpm(wpm_sd) wpm_min = wpm_to_cpm(wpm_min) wpm_max = wpm_to_cpm(wpm_max) wpm_ci0 = wpm_to_cpm(wpm_ci0) wpm_ci1 = wpm_to_cpm(wpm_ci1) wpm_pi0 = wpm_to_cpm(wpm_pi0) wpm_pi1 = wpm_to_cpm(wpm_pi1) if cpm_flag: msg = "cpm %5.1f min %5.1f avg %5.1f max %5.1f sd %2d%% ci [%5.1f-%5.1f] [%5.1f-%5.1f] pi (n=%d)" else: msg = "wpm %5.1f min %5.1f avg %5.1f max %5.1f sd %2d%% ci [%5.1f-%5.1f] [%5.1f-%5.1f] pi (n=%d)" msg %= (wpm_min, wpm_avg, wpm_max, wpm_sd, 100*percent, wpm_ci0, wpm_ci1, wpm_pi0, wpm_pi1, samples) self.cheight += 2 self.addstr(0, self.cheight, msg, Screen.COLOR_CORRECT) msg = "acc %5.1f min %5.1f avg %5.1f max %5.1f sd %2d%% ci [%5.1f %5.1f] [%5.1f %5.1f] pi (n=%d)" % ( 100*acc_min, 100*acc_avg, 100*acc_max, 100*acc_sd, 100*percent, 100*acc_ci0, 100*acc_ci1, 100*acc_pi0, 100*acc_pi1, samples) self.cheight += 1 self.addstr(0, self.cheight, msg, Screen.COLOR_CORRECT) self.cheight += 1 if devfeature.histogram: self.show_histogram(stats) def show_score(self, head, wpm_score, stats, cpm_flag): """Show score screen after typing has finished.""" if not self.redraw: return self.update_header(head) self.update_quote(Screen.COLOR_CORRECT) self.update_author() # Highlight score if cpm_flag: wpm_score = wpm_to_cpm(wpm_score) score = "You scored %.1f CPM!" % wpm_score else: score = "You scored %.1f WPM!" % wpm_score self.update_prompt(score) if len(score) < self.columns: self.chgat(11, self.cheight, len(str("%.1f" % wpm_score)), Screen.COLOR_HISCORE) self.show_help() self.show_stats(stats, cpm_flag) self.set_cursor(0, 2) self.redraw = False def highlight_progress(self, position, incorrect): """Colors finished and incorrectly typed parts of the quote.""" if incorrect: color = Screen.COLOR_INCORRECT else: color = Screen.COLOR_CORRECT # Highlight correct / incorrect character in quote ixpos, iypos = self.quote_coords[position + incorrect - 1] color = Screen.COLOR_INCORRECT if incorrect else Screen.COLOR_CORRECT self.chgat(max(ixpos, 0), 2 + iypos, 1, color) # Highlight next as correct, in case of backspace xpos, ypos = self.quote_coords[position + incorrect] self.chgat(xpos, 2 + ypos, 1, Screen.COLOR_QUOTE) def show_keystroke(self, head, position, incorrect, typed, key): """Updates the screen while typing.""" self.update_header(head) if key and (position + incorrect) <= len(self.quote): self.highlight_progress(position, incorrect) self.update_prompt("> " + typed) # Move cursor to current position in text before refreshing xpos, ypos = self.quote_coords[position + incorrect] self.set_cursor(min(xpos, self.quote_columns - 1), 2 + ypos) def rerender_race(self, head): """Re-renders currently running game.""" self.clear() self.update_header(head) self.update_quote(Screen.COLOR_QUOTE) self.update_author() self.set_cursor(0, 2) def clear(self): """Clears the screen.""" self.redraw = True self.window.clear() def deinit(self): """Deinitializes curses.""" curses.nocbreak() self.screen.keypad(False) curses.echo() curses.endwin()