initial push

This commit is contained in:
Doman 2021-07-10 16:44:01 +02:00
parent 1d5dabf757
commit b412c28f56
9 changed files with 416 additions and 0 deletions

0
fasttyper/__init__.py Normal file
View file

74
fasttyper/__main__.py Normal file
View file

@ -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()

47
fasttyper/application.py Normal file
View file

@ -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

83
fasttyper/buffer.py Normal file
View file

@ -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())

56
fasttyper/components.py Normal file
View file

@ -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))

37
fasttyper/interface.py Normal file
View file

@ -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)

40
fasttyper/listener.py Normal file
View file

@ -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()

35
fasttyper/state.py Normal file
View file

@ -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

44
fasttyper/stats.py Normal file
View file

@ -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