change components to inherited boxes, stats are updated by buffer which stores both user and reference text

This commit is contained in:
Doman 2022-03-06 17:40:00 +01:00
parent 6cb025c92a
commit 365000701f
8 changed files with 303 additions and 306 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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