initial push
This commit is contained in:
parent
1d5dabf757
commit
b412c28f56
9 changed files with 416 additions and 0 deletions
0
fasttyper/__init__.py
Normal file
0
fasttyper/__init__.py
Normal file
74
fasttyper/__main__.py
Normal file
74
fasttyper/__main__.py
Normal 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
47
fasttyper/application.py
Normal 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
83
fasttyper/buffer.py
Normal 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
56
fasttyper/components.py
Normal 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
37
fasttyper/interface.py
Normal 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
40
fasttyper/listener.py
Normal 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
35
fasttyper/state.py
Normal 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
44
fasttyper/stats.py
Normal 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
|
Loading…
Reference in a new issue