From b412c28f56ec2f09a988d9fed5b2c3cdc1b8ab8e Mon Sep 17 00:00:00 2001 From: Doman Date: Sat, 10 Jul 2021 16:44:01 +0200 Subject: [PATCH] initial push --- fasttyper/__init__.py | 0 fasttyper/__main__.py | 74 +++++++++++++++++++++++++++++++++++ fasttyper/application.py | 47 +++++++++++++++++++++++ fasttyper/buffer.py | 83 ++++++++++++++++++++++++++++++++++++++++ fasttyper/components.py | 56 +++++++++++++++++++++++++++ fasttyper/interface.py | 37 ++++++++++++++++++ fasttyper/listener.py | 40 +++++++++++++++++++ fasttyper/state.py | 35 +++++++++++++++++ fasttyper/stats.py | 44 +++++++++++++++++++++ 9 files changed, 416 insertions(+) create mode 100644 fasttyper/__init__.py create mode 100644 fasttyper/__main__.py create mode 100644 fasttyper/application.py create mode 100644 fasttyper/buffer.py create mode 100644 fasttyper/components.py create mode 100644 fasttyper/interface.py create mode 100644 fasttyper/listener.py create mode 100644 fasttyper/state.py create mode 100644 fasttyper/stats.py diff --git a/fasttyper/__init__.py b/fasttyper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/fasttyper/__main__.py b/fasttyper/__main__.py new file mode 100644 index 0000000..3ec16b6 --- /dev/null +++ b/fasttyper/__main__.py @@ -0,0 +1,74 @@ +from .application import Application +from .interface import Interface +from .components import UserInput, CursorComponent, ReferenceText, StatsComponent +from .listener import Listener +from .buffer import UserBuffer, Buffer +from curses import wrapper +import sys +import io +import os + + +def helpexit(): + print( + "USAGE: fasttyper FILE or pipe text into fasttyper after executing 'exec 3<&0' in your shell" + ) + sys.exit(1) + + +def main(): + if len(sys.argv) == 1: + input_lines = sys.stdin.readlines() + os.dup2(3, 0) + rbuffer = io.StringIO("".join(input_lines)) + elif len(sys.argv) == 2: + f = sys.argv[1] + try: + rbuffer = open(f) + except: + helpexit() + else: + helpexit() + + reference_buffer = Buffer(rbuffer) + user_buffer = UserBuffer() + + cursor_component = CursorComponent() + user_input = UserInput(cursor_component) + reference_text = ReferenceText() + stats_component = StatsComponent() + + listener = Listener() + application = Application(listener, user_buffer, reference_buffer) + + interface = Interface( + application, + [user_input, reference_text, stats_component, cursor_component], + ) + wrapper(interface) + user_buffer.close() + reference_buffer.close() + + stats = application.stats + print( + "\n".join( + [ + f"WPM: {stats.correct_words / stats.total_minutes}", + f"CPM: {stats.correct_chars / stats.total_minutes}", + f"RAW WPM: {(stats.correct_words + stats.incorrect_words) / stats.total_minutes}", + f"RAW CPM: {(stats.correct_chars + stats.incorrect_chars) / stats.total_minutes}", + f"total seconds: {stats.total_seconds}", + f"total minutes: {stats.total_minutes}", + f"correct words: {stats.correct_words}", + f"correct chars: {stats.correct_chars}", + f"incorrect words: {stats.incorrect_words}", + f"incorrect chars: {stats.incorrect_chars}", + f"total words: {stats.incorrect_words + stats.correct_words}", + f"total chars: {stats.incorrect_chars + stats.correct_chars}", + ] + ) + ) + + +if __name__ == "__main__": + main() diff --git a/fasttyper/application.py b/fasttyper/application.py new file mode 100644 index 0000000..02515fe --- /dev/null +++ b/fasttyper/application.py @@ -0,0 +1,47 @@ +class Application: + def __init__(self, listener, user_buffer, reference_buffer): + self._running = False + self.listener = listener + self.user_buffer = user_buffer + self.reference_buffer = reference_buffer + + from .state import StateMachine + + self.state = StateMachine(self) + + from .stats import Stats + + self.stats = Stats() + + def start(self): + self._running = True + + def running(self): + return self._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 get_user_text(self): + text = self.user_buffer.get_matrix() + mistake_position = self.valid_user_text_position() + return text[:mistake_position], text[mistake_position:] + + def get_reference_text(self, position): + return self.reference_buffer.get_matrix(position) + + 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()) + except StoppingSignal: + self._running = False + self.stats.signal_stop() + + +class StoppingSignal(Exception): + pass diff --git a/fasttyper/buffer.py b/fasttyper/buffer.py new file mode 100644 index 0000000..830697c --- /dev/null +++ b/fasttyper/buffer.py @@ -0,0 +1,83 @@ +import io +from .listener import Action + + +class Buffer: + def __init__(self, buffer): + self.buffer = buffer + + def _write(self, data): + self.buffer.write(data) + + def _del_char(self): + pos = self.buffer.tell() + + if pos > 0: + self.buffer.seek(pos - 1) + self.buffer.truncate() + return True + + def _last_char(self): + pos = self.buffer.tell() + + if pos > 0: + self.buffer.seek(pos - 1) + return self.buffer.read(1) + + def _del_word(self): + found_word = False + + while True: + last_char = self._last_char() + + if last_char is None: + break + + if last_char in (" ", "\n") and found_word: + break + + if last_char.isalnum() and not found_word: + found_word = True + + self._del_char() + + 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") + elif action == Action.del_char: + self._del_char() + elif action == Action.del_word: + self._del_word() + + def get_matrix(self, position=0): + self.buffer.seek(position) + return self.buffer.read() + + def get_position(self): + return self.buffer.tell() + + def get_lenght(self): + position = self.get_position() + self.buffer.read() + lenght = self.get_position() + self.buffer.seek(position) + return lenght + + def read(self, position): + _position = self.get_position() + self.buffer.seek(position) + char = self._last_char() + self.buffer.seek(_position) + return char + + def close(self): + self.buffer.close() + + +class UserBuffer(Buffer): + def __init__(self): + super().__init__(io.StringIO()) diff --git a/fasttyper/components.py b/fasttyper/components.py new file mode 100644 index 0000000..d953309 --- /dev/null +++ b/fasttyper/components.py @@ -0,0 +1,56 @@ +import curses + + +class Base: + def __init__(self): + self.rows = None + self.cols = None + + def init(self, screen): + self.rows, self.cols = screen.getmaxyx() + + def paint(self, screen, application): + pass + + +class CursorComponent(Base): + def __init__(self): + super().__init__() + self.cursor_position = None + + def update(self, screen): + self.cursor_position = screen.getyx() + + def paint(self, screen, application): + screen.move(*self.cursor_position) + + +class UserInput(Base): + def __init__(self, cursor_component): + super().__init__() + self.cursor_component = cursor_component + + def paint(self, screen, application): + valid_text, invalid_text = application.get_user_text() + + invalid_text = invalid_text.replace(" ", "_") + invalid_text = invalid_text.replace("\n", "\\n") + + screen.addstr(valid_text, curses.color_pair(3)) + screen.addstr(invalid_text, curses.color_pair(2)) + self.cursor_component.update(screen) + + +class ReferenceText(Base): + def paint(self, screen, application): + valid_user_text_position = application.valid_user_text_position() + reference_text = application.get_reference_text(valid_user_text_position) + reference_text = reference_text.replace("\n", "\\n\n") + screen.addstr(reference_text, curses.color_pair(8)) + + +class StatsComponent(Base): + def paint(self, screen, application): + text = f"\n\nwpm: {application.stats.correct_words / application.stats.total_minutes}" + text += f"\ntime: {application.stats.total_seconds}s" + screen.addstr(text, curses.color_pair(5)) diff --git a/fasttyper/interface.py b/fasttyper/interface.py new file mode 100644 index 0000000..cb960a7 --- /dev/null +++ b/fasttyper/interface.py @@ -0,0 +1,37 @@ +import curses + + +class Interface: + def __init__(self, application, components): + self.application = application + self.components = components + + def init_colors(self): + assert curses.has_colors() + curses.start_color() + curses.use_default_colors() + + for i in range(0, curses.COLORS): + curses.init_pair(i + 1, i, -1) + + def init(self, screen): + self.init_colors() + for component in self.components: + component.init(screen) + + def update(self, screen): + screen.clear() + for component in self.components: + component.paint(screen, self.application) + screen.refresh() + + def __call__(self, screen): + """ + Main running loop + """ + self.init(screen) + self.application.start() + + while self.application.running(): + self.update(screen) + self.application.action(screen) diff --git a/fasttyper/listener.py b/fasttyper/listener.py new file mode 100644 index 0000000..5e79153 --- /dev/null +++ b/fasttyper/listener.py @@ -0,0 +1,40 @@ +import enum +from .application import StoppingSignal + + +class Action(enum.Enum): + add_char = "add_char" + add_space = "add_space" + add_newline = "add_newline" + del_char = "del_char" + del_word = "del_word" + invalid = "invalid" + + +class Listener: + def __init__(self): + pass + + def handle_key(self, key): + if key == "KEY_BACKSPACE": + return Action.del_word, key + elif key == chr(127): + return Action.del_char, key + elif key.startswith("KEY"): + # special key pressed, or compbination + return Action.invalid, key + elif key == " ": + return Action.add_space, key + elif key == "\n": + return Action.add_newline, key + elif key.isprintable(): + return Action.add_char, key + + return Action.invalid, key + + def listen(self, screen): + try: + key = screen.getkey() + return self.handle_key(key) + except KeyboardInterrupt: + raise StoppingSignal() diff --git a/fasttyper/state.py b/fasttyper/state.py new file mode 100644 index 0000000..bca3b33 --- /dev/null +++ b/fasttyper/state.py @@ -0,0 +1,35 @@ +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 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): + self.mistake_position = user_position - 1 + self.state = State.invalid + elif user_position == self.application.reference_buffer.get_lenght(): + self.state = State.finished + raise StoppingSignal + + def valid(self): + return self.state == State.valid diff --git a/fasttyper/stats.py b/fasttyper/stats.py new file mode 100644 index 0000000..53bc963 --- /dev/null +++ b/fasttyper/stats.py @@ -0,0 +1,44 @@ +from .listener import Action +from datetime import datetime + + +class Stats: + def __init__(self): + self.correct_words = 0 + self.correct_chars = 0 + self.incorrect_words = 0 + self.incorrect_chars = 0 + + self.start_dtime = None + self.stop_dtime = None + + def signal_running(self): + if self.start_dtime is None: + self.start_dtime = datetime.now() + + def update(self, action, valid): + self.signal_running() + + 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 + + def signal_stop(self): + self.stop_dtime = datetime.now() + + @property + def total_seconds(self): + stop_dtime = self.stop_dtime or datetime.now() + start_dtime = self.start_dtime or datetime.now() + return (stop_dtime - start_dtime).total_seconds() or 1 + + @property + def total_minutes(self): + return self.total_seconds / 60