From 365000701f99be2a371d4ce7b3f5ffd790c918d9 Mon Sep 17 00:00:00 2001 From: Doman Date: Sun, 6 Mar 2022 17:40:00 +0100 Subject: [PATCH] change components to inherited boxes, stats are updated by buffer which stores both user and reference text --- fasttyper/application.py | 42 +---- fasttyper/buffer.py | 107 ++++++++---- fasttyper/cli.py | 22 +-- fasttyper/components.py | 361 ++++++++++++++++++++++----------------- fasttyper/config.py | 3 +- fasttyper/interface.py | 2 - fasttyper/state.py | 45 ----- fasttyper/stats.py | 27 +-- 8 files changed, 303 insertions(+), 306 deletions(-) delete mode 100644 fasttyper/state.py diff --git a/fasttyper/application.py b/fasttyper/application.py index 181186e..e4f2e99 100644 --- a/fasttyper/application.py +++ b/fasttyper/application.py @@ -3,59 +3,31 @@ import readchar class Application: - def __init__(self, listener, user_buffer, reference_buffer, config): + def __init__(self, listener, buffer, config): self.listener = listener - self.user_buffer = user_buffer - self.reference_buffer = reference_buffer + self.buffer = buffer self.config = config self.finished = False self.silent_exit = False - from .state import StateMachine - - self.state = StateMachine(self) - - from .stats import Stats - - self.stats = Stats() - def start(self): - self.state.signal_start() + pass def running(self): - return self.state.running() - - def valid_user_text_position(self): - if self.state.mistake_position is not None: - return self.state.mistake_position - return self.user_buffer.get_position() - - def user_position(self): - return self.user_buffer.get_position() - - def get_text(self): - user_text = self.user_buffer.get_matrix() - reference_text = self.reference_buffer.get_matrix( - self.valid_user_text_position() - ) - return user_text + reference_text + return True def action(self, screen): try: action, key = self.listener.listen(screen) - self.user_buffer.handle_action(action, key) - self.state.update(action, key) - self.stats.update(action, self.state.valid(), self.state.running()) + self.buffer.handle_action(action, key) except StoppingSignal as e: - self.state.signal_stop() - self.stats.signal_stop() if e.silent: self.silent_exit = True def summarize(self): if self.finished: - self.stats.summarize(self.config.get("summary_template")) - self.stats.export_to_datafile(self.config.get("summary_datafile")) + self.buffer.stats.summarize(self.config.get("summary_template")) + self.buffer.stats.export_to_datafile(self.config.get("summary_datafile")) try: readchar.readchar() except KeyboardInterrupt: diff --git a/fasttyper/buffer.py b/fasttyper/buffer.py index 9818366..f9187b7 100644 --- a/fasttyper/buffer.py +++ b/fasttyper/buffer.py @@ -1,53 +1,102 @@ from .listener import Action +from enum import Enum + + +class CharType(Enum): + reference = 0 + valid = 1 + invalid = 2 class Buffer: - def __init__(self, buffer): - self.buffer = " ".join(buffer.split()) + def __init__(self, buffer, text_box, stats): + self.reference_words = buffer.split() + self.user_words = [] - def _write(self, data): - self.buffer += data + self.total_words = len(self.reference_words) + self.current_word = 0 + self.current_char = 0 + + self.text_box = text_box + self.stats = stats + + self.text_box.set_buffer(self) + + def _write(self, char): + while self.current_word >= len(self.user_words): + self.user_words.append("") + + valid = False + try: + valid = ( + self.reference_words[self.current_word][ + len(self.user_words[self.current_word]) + ] + == char + ) + except IndexError: + pass + + if valid: + self.stats.signal_valid() + else: + self.stats.signal_invalid() + + self.user_words[self.current_word] += char + self.current_char += 1 + + def _next_word(self): + self.current_word += 1 + self.current_char = 0 + self.stats.signal_valid() # space is a char after all def _del_char(self): - self.buffer = self.buffer[:-1] + try: + self.user_words[self.current_word] = self.user_words[self.current_word][:-1] + self.current_char -= 1 + assert self.current_char >= 0 + except (IndexError, AssertionError): + self.user_words = self.user_words[: self.current_word] + self.current_word = max(0, self.current_word - 1) - def _last_char(self): - if len(self.buffer) > 0: - return self.buffer[-1] + if self.current_word < len(self.user_words): + self.current_char = len(self.user_words[self.current_word]) + else: + self.current_char = 0 def _del_word(self): - words = self.buffer.split() - - if len(words) > 1: - self.buffer = " ".join(words[:-1]) + " " - else: - self.buffer = "" + try: + self.user_words[self.current_word] = "" + self.current_char = 0 + except IndexError: + self.current_word = max(0, self.current_word - 1) def handle_action(self, action, char): if action == Action.add_char: self._write(char) elif action == Action.add_space: - self._write(" ") - elif action == Action.add_newline: - self._write("\n") + self._next_word() elif action == Action.del_char: self._del_char() elif action == Action.del_word: self._del_word() - def get_matrix(self, position=0): - return self.buffer[position:] + self.text_box.update_current_word(self.current_word) - def get_position(self): - return len(self.buffer) + def get_word(self, index): + reference_word = self.reference_words[index] + user_word = "" - def get_lenght(self): - return len(self.buffer) + try: + user_word = self.user_words[index] + except IndexError: + pass - def read(self, position): - return self.buffer[position] + word = [ + (r, CharType.valid if r == u else CharType.invalid) + for r, u in zip(reference_word, user_word) + ] + word += [(c, CharType.invalid) for c in user_word[len(word) :]] + word += [(c, CharType.reference) for c in reference_word[len(word) :]] - -class UserBuffer(Buffer): - def __init__(self): - super().__init__("") + return word diff --git a/fasttyper/cli.py b/fasttyper/cli.py index fd73d3b..20dc2cb 100644 --- a/fasttyper/cli.py +++ b/fasttyper/cli.py @@ -1,14 +1,12 @@ from .application import Application from .interface import Interface from .components import ( - CursorComponent, - StatsComponent, TextBox, - TopMargin, ) from .listener import Listener -from .buffer import UserBuffer, Buffer +from .buffer import Buffer from .config import Config +from .stats import Stats from curses import wrapper import os import argparse @@ -24,24 +22,18 @@ def initialize(config_path, rbuffer, backspace_debug, no_cursor): config = Config(configmap) - reference_buffer = Buffer(rbuffer) - user_buffer = UserBuffer() + text_box = TextBox(config) + stats = Stats() - top_margin = TopMargin(config) - cursor_component = CursorComponent(config) - text_box = TextBox(config, cursor_component) - stats_component = StatsComponent(config) + buffer = Buffer(rbuffer, text_box, stats) listener = Listener(backspace_debug) - application = Application(listener, user_buffer, reference_buffer, config) + application = Application(listener, buffer, config) interface = Interface( application, [ - top_margin, text_box, - stats_component, - cursor_component, ], no_cursor, ) @@ -58,7 +50,7 @@ def get_parser(): "-c", metavar="FILE", help="configuration file", - default="~/.config/fasttyper/config.json", + default="~/.config/fasttyper/config_debug.json", ) parser.add_argument( "--unclutter-backspace", diff --git a/fasttyper/components.py b/fasttyper/components.py index 6eb3f12..a6bc145 100644 --- a/fasttyper/components.py +++ b/fasttyper/components.py @@ -1,191 +1,236 @@ import curses -class Base: +class WindowComponent: + """ + Basic text component printing inside window + """ + + def __init__(self, config): + self._window = None + self._height = None + self._width = None + self._begin_x = None + self._begin_y = None + + self.cursor_x, self.cursor_y = 0, 0 + + def update_size(self, height, width, begin_x, begin_y): + self._height = height + self._width = width + self._begin_x = begin_x + self._begin_y = begin_y + + def init_window(self): + self._window = curses.newwin( + self._height, + self._width, + self._begin_y, + self._begin_x, + ) + + def set_box(self, i): + self._window.box(i, i) + + def paint_text(self, row, col, text, color): + self._window.addstr(row, col, text, curses.color_pair(color)) + + def move(self, x, y): + self._window.move(x, y) + def paint(self, screen, application): pass + def refresh(self): + self._window.refresh() + + +class BorderedBox(WindowComponent): + """ + Adds border to WindowComponent + """ -class CursorComponent(Base): def __init__(self, config): - super().__init__() - self.cursor_position = None + super().__init__(config) - def update(self, screen): - self.cursor_position = screen.getyx() + self.maxy, self.maxx = None, None - def paint(self, screen, application): - if self.cursor_position: - screen.move(*self.cursor_position) + self.pos_y = config.get("top_margin_percentage") / 100 + self.pos_x = config.get("left_margin_percentage") / 100 + self.height = config.get("lines_on_screen") + self.width = None + def paint_text(self, row, col, text, color): + super().paint_text(row + 1, col + 1, text, color) -class TextComponent(Base): - def paint_text(self, screen, text, color): - screen.addstr(text, curses.color_pair(color)) - - -class StatsComponent(TextComponent): - def __init__(self, config): - super().__init__() - self.color = config.get("stats_color") - self.template = config.get("stats_template") + def move(self, x, y): + super().move(x + 1, y + 1) def init(self, screen, application): - super().init(screen, application) + self.width = int(self.maxx * (1 - 2 * self.pos_x)) + self.update_size( + self.height + 2, + self.width + 2, + int(self.pos_x * self.maxx) - 1, + int(self.pos_y * self.maxy) - 1, + ) + self.init_window() + self.set_box(1) - def paint(self, screen, application): - usedy, _ = screen.getyx() - maxy, _ = screen.getmaxyx() - - text = self.template.format(stats=application.stats) - - texty = len(text.splitlines()) - - if texty + usedy + 1 < maxy: - self.paint_text(screen, text, self.color) + screen.refresh() -class TopMargin(Base): +class BufferDependentComponent(BorderedBox): + """ + Adds content source from buffer + """ + def __init__(self, config): - super().__init__() - self.height = config.get("top_margin_percentage") / 100 + super().__init__(config) - def paint(self, screen, application): - maxy, _ = screen.getmaxyx() - lines = int(self.height * maxy) - for line in range(lines): - screen.addstr("\n") + self.buffered_lines = 0 + self.buffer = None + self.last_hidden_word = 0 + self.lines = [[] for _ in range(self.height)] + + from .buffer import CharType + + self.chtype_mapper = { + CharType.valid: config.get("user_input_valid_color"), + CharType.invalid: config.get("user_input_invalid_color"), + CharType.reference: config.get("reference_text_color"), + } + + self.current_word_idx = 0 + self.current_line, self.word_index = 0, 0 + + def set_buffer(self, buffer): + self.buffer = buffer + + def line_len(self, index): + spaces = max(len(self.lines[index]) - 1, 0) + return sum([len(w) for w in self.lines[index]]) + spaces + + def paint_line(self, line_nr): + pos = 0 + + for i, word in enumerate(self.lines[line_nr]): + for c in word: + self.paint_text(line_nr, pos, c[0], self.chtype_mapper[c[1]]) + pos += 1 + + if i != len(self.lines[line_nr]): + self.paint_text(line_nr, pos, " ", 0) + pos += 1 + + def fill_lines(self): + line_nr = self.buffered_lines + start_idx = self.last_hidden_word + sum( + [len(line) for line in self.lines[:line_nr]] + ) + + for i in range(start_idx, self.buffer.total_words): + word = self.buffer.get_word(i) + + if self.line_len(line_nr) + len(word) + 1 > self.width: + self.paint_line(line_nr) + line_nr += 1 + + if line_nr >= self.height: + break + + self.lines[line_nr].append(word) + + self.paint_line(line_nr) + self.buffered_lines = self.height + + def update_cursor(self): + self.cursor_x = self.current_line + past_words = self.lines[self.current_line][: self.word_index] + self.cursor_y = ( + sum([len(w) for w in past_words]) + + len(past_words) + + self.buffer.current_char + ) + + def update_current_word(self, word_index): + """ + This is called by buffer. It signals that user changed its state. + + First, active word is updated. It changes on added char or deleted word. + """ + + old_len = len(self.lines[self.current_line][self.word_index]) + self.lines[self.current_line][self.word_index] = self.buffer.get_word( + self.current_word_idx + ) + new_len = len(self.lines[self.current_line][self.word_index]) + + if old_len != new_len: + pass + + while word_index > self.current_word_idx: + self.word_index += 1 + + if self.word_index >= len(self.lines[self.current_line]): + self.word_index = 0 + self.current_line += 1 + + self.current_word_idx += 1 + self.lines[self.current_line][self.word_index] = self.buffer.get_word( + self.current_word_idx + ) + + while word_index < self.current_word_idx: + self.word_index -= 1 + + if self.word_index == -1: + self.current_line -= 1 + self.word_index = len(self.lines[self.current_line] - 1) + + self.current_word_idx -= 1 + self.lines[self.current_line][self.word_index] = self.buffer.get_word( + self.current_word_idx + ) + + self.update_cursor() -class TextBox(TextComponent): +class BorderWithImprintedStats(BufferDependentComponent): """ - Wraps lines of text elements writing to it nicely + Imprints stats on one of borders """ - def __init__(self, config, cursor_component): - super().__init__() - self.cursor_component = cursor_component + def __init__(self, config): + super().__init__(config) - self.maxy, self.maxx = None, None - self.usedy, self.usedx = None, None + self.stats_template = config.get("stats_template").replace("\n", " ") + self.stats_color = config.get("stats_color") + self.stats_row = -1 if config.get("stats_position") == "top" else self.height - self.left_margin = config.get("left_margin_percentage") + def paint_stats(self): + text = self.stats_template.format(stats=self.buffer.stats) + if len(text) < self.width - 2: + self.paint_text(self.stats_row, 2, text, self.stats_color) - self.valid_color = config.get("user_input_valid_color") - self.invalid_color = config.get("user_input_invalid_color") - self.color = config.get("reference_text_color") - self.lines_on_screen = config.get("lines_on_screen") +class TextBox(BorderWithImprintedStats): + """ + Calls all inherited paint functions and inits windows + """ - def clear(self): - self.maxy, self.maxx = None, None - self.usedy, self.usedx = None, None - - @property - def max_line_x(self): - return int(self.maxx * (100 - self.left_margin - self.left_margin) / 100) - 1 - - @property - def padding(self): - return " " * int(1 + self.left_margin * self.maxx / 100) - - def lines_to_display(self, application): - text = application.get_text() - valid_position = application.valid_user_text_position() - user_position = application.user_position() - - lines = [] - line = "" - word = "" - - valid_pointer = (0, 0) - user_pointer = (0, 0) - - for i, c in enumerate(text): - word += c - - if len(line + word) > self.max_line_x: - lines.append(line) - line = "" - - if not c.isalnum(): - word = str(word)[:-1] + " " - line += word - word = "" - - if word: - line += word - - if line: - lines.append(line) - - position = 0 - for i, l in enumerate(lines): - st, end = position, position + len(l) - if user_position >= st and user_position <= end: - user_pointer = (i, user_position - st) - if valid_position >= st and valid_position <= end: - valid_pointer = (i, valid_position - st) - position = end - - return lines, valid_pointer, user_pointer - - def paint_line(self, i, line, valid_pointer, user_pointer, screen): - if i < valid_pointer[0]: - # easy, we are in written line - self.paint_text(screen, line, self.valid_color) - self.cursor_component.update(screen) - return - - if i > user_pointer[0]: - # easy, we are in reference line - self.paint_text(screen, line, self.color) - return - - valid_text = "" - invalid_text = "" - invalid_start = 0 - - if i == valid_pointer[0]: - valid_text = line[: valid_pointer[1]] - invalid_start = valid_pointer[1] - invalid_text = line[valid_pointer[1] :] - if i == user_pointer[0]: - invalid_text = line[invalid_start : user_pointer[1]] - - reference_text = line[len(invalid_text) + len(valid_text) :] - invalid_text = invalid_text.replace(" ", "_") - - self.paint_text(screen, valid_text, self.valid_color) - self.paint_text(screen, invalid_text, self.invalid_color) - self.cursor_component.update(screen) - self.paint_text(screen, reference_text, self.color) + def __init__(self, config): + super().__init__(config) def paint(self, screen, application): - self.maxy, self.maxx = screen.getmaxyx() - self.usedy, self.usedx = screen.getyx() + if self._window is None: + self.maxy, self.maxx = screen.getmaxyx() + self.init(screen, application) - lines, valid_pointer, user_pointer = self.lines_to_display(application) + if self.buffered_lines < self.height: + self.fill_lines() - lines_fitting = min((self.maxy - self.usedy, self.lines_on_screen)) - start = 0 - end = len(lines) - - if lines_fitting == 0: - raise Exception("Too small display!") - - if lines_fitting <= len(lines): - previous_lines = 1 if lines_fitting > 2 else 0 - next_lines = 1 if lines_fitting > 1 else 0 - start = user_pointer[0] - previous_lines - end = user_pointer[0] + next_lines - if start == -1: - end += 1 - - for i, line in enumerate(lines): - if i >= start and i <= end: - screen.addstr(self.padding) - self.paint_line(i, line, valid_pointer, user_pointer, screen) - screen.addstr("\n") - - self.clear() + self.paint_stats() + self.paint_line(self.current_line) + self.move(self.cursor_x, self.cursor_y) + self.refresh() diff --git a/fasttyper/config.py b/fasttyper/config.py index 77c5041..3981417 100644 --- a/fasttyper/config.py +++ b/fasttyper/config.py @@ -3,8 +3,9 @@ class Config: "user_input_valid_color": 3, "user_input_invalid_color": 2, "reference_text_color": 8, - "stats_template": "\n\nwpm: {stats.wpm:0.2f}\ntime: {stats.total_seconds:0.2f}s", + "stats_template": "wpm: {stats.wpm:0.2f}, time: {stats.total_seconds:0.2f}s", "stats_color": 5, + "stats_position": "top", "summary_template": ( "WPM: {stats.wpm:0.2f}\n" "CPM: {stats.cpm:0.2f}\n" diff --git a/fasttyper/interface.py b/fasttyper/interface.py index d957ce7..241b3bb 100644 --- a/fasttyper/interface.py +++ b/fasttyper/interface.py @@ -19,10 +19,8 @@ class Interface: self.init_colors() def update(self, screen): - screen.clear() for component in self.components: component.paint(screen, self.application) - screen.refresh() def __call__(self, screen): """ diff --git a/fasttyper/state.py b/fasttyper/state.py deleted file mode 100644 index 6dbcdc5..0000000 --- a/fasttyper/state.py +++ /dev/null @@ -1,45 +0,0 @@ -import enum -from .listener import Action -from .application import StoppingSignal - - -class State(enum.Enum): - valid = "valid" - invalid = "invalid" - finished = "finished" - - -class StateMachine: - def __init__(self, application): - self.state = State.valid - self.mistake_position = None - self.application = application - - def running(self): - return self.state != State.finished - - def signal_stop(self): - self.state = State.finished - - def signal_start(self): - self.state = State.valid - self.mistake_position = None - - def update(self, action, char): - user_position = self.application.user_buffer.get_position() - if self.state == State.invalid: - if action in (Action.del_char, Action.del_word): - if user_position <= self.mistake_position: - self.mistake_position = None - self.state = State.valid - if self.state == State.valid: - if action in (Action.add_char, Action.add_space, Action.add_newline): - if char != self.application.reference_buffer.read(user_position - 1): - self.mistake_position = user_position - 1 - self.state = State.invalid - elif user_position == self.application.reference_buffer.get_lenght(): - self.state = State.finished - self.application.finished = True - - def valid(self): - return self.state in (State.valid, State.finished) diff --git a/fasttyper/stats.py b/fasttyper/stats.py index 8f055bf..6b9fe40 100644 --- a/fasttyper/stats.py +++ b/fasttyper/stats.py @@ -19,28 +19,13 @@ class Stats: if self.start_dtime is None: self.start_dtime = datetime.now() - def update(self, action, valid, running): - if running: - self.signal_running() + def signal_valid(self): + self.signal_running() + self.correct_chars += 1 - if action == Action.add_char and valid is True: - self.correct_chars += 1 - elif action == Action.add_char and valid is False: - self.incorrect_chars += 1 - elif action in (Action.add_space, Action.add_newline) and valid is True: - self.correct_chars += 1 - self.correct_words += 1 - elif action in (Action.add_space, Action.add_newline) and valid is False: - self.incorrect_chars += 1 - self.incorrect_words += 1 - - if not running: - self.signal_stop() - - if valid: - self.correct_words += 1 - else: - self.incorrect_words += 1 + def signal_invalid(self): + self.signal_running() + self.incorrect_chars += 1 def signal_stop(self): self.stop_dtime = datetime.now()