typerace 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- typerace/__init__.py +3 -0
- typerace/__main__.py +5 -0
- typerace/app.py +578 -0
- typerace/quotes.json +1 -0
- typerace-0.1.0.dist-info/METADATA +129 -0
- typerace-0.1.0.dist-info/RECORD +11 -0
- typerace-0.1.0.dist-info/WHEEL +5 -0
- typerace-0.1.0.dist-info/entry_points.txt +2 -0
- typerace-0.1.0.dist-info/licenses/LICENSE +674 -0
- typerace-0.1.0.dist-info/licenses/QUOTES.md +10 -0
- typerace-0.1.0.dist-info/top_level.txt +1 -0
typerace/__init__.py
ADDED
typerace/__main__.py
ADDED
typerace/app.py
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"""typerace: TypeRacer-style typing races in the terminal.
|
|
2
|
+
|
|
3
|
+
Type a quote word by word, as fast as you can. The input box only clears once a
|
|
4
|
+
word (and its space) is right; mistakes show red and must be fixed.
|
|
5
|
+
|
|
6
|
+
Keys: Ctrl+Backspace / Ctrl+W / Alt+Backspace = clear the word,
|
|
7
|
+
Tab = new quote, Esc = quit (q also quits from the results screen).
|
|
8
|
+
|
|
9
|
+
Results, and which quotes you've already done, are kept in
|
|
10
|
+
$XDG_DATA_HOME/typerace (default ~/.local/share/typerace).
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import csv
|
|
14
|
+
import curses
|
|
15
|
+
import datetime as dt
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import random
|
|
19
|
+
import sys
|
|
20
|
+
import textwrap
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from . import __version__
|
|
24
|
+
|
|
25
|
+
DATA = os.path.join(os.environ.get("XDG_DATA_HOME") or os.path.expanduser("~/.local/share"), "typerace")
|
|
26
|
+
HISTORY = os.path.join(DATA, "history.csv")
|
|
27
|
+
SEEN_FILE = os.path.join(DATA, "seen.json")
|
|
28
|
+
# length groups (characters) and how often each comes up in a mixed race
|
|
29
|
+
LENGTHS = {"short": (0, 100, 25), "medium": (101, 300, 45), "long": (301, 600, 25), "verylong": (601, 99999, 5)}
|
|
30
|
+
# the car on the race lane; set TYPERACE_NERD_FONT=1 for a Nerd Font car icon
|
|
31
|
+
CAR = "\U000F010B" if os.environ.get("TYPERACE_NERD_FONT") else "\u25b6"
|
|
32
|
+
|
|
33
|
+
# fallback if the bundled quotes can't be read: (passage, "Author, Title"),
|
|
34
|
+
# all public domain
|
|
35
|
+
QUOTES = [
|
|
36
|
+
("It was the best of times, it was the worst of times, it was the age of wisdom, it was the age of foolishness, "
|
|
37
|
+
"it was the epoch of belief, it was the epoch of incredulity, it was the season of Light, it was the season of "
|
|
38
|
+
"Darkness, it was the spring of hope, it was the winter of despair.", "Charles Dickens, A Tale of Two Cities"),
|
|
39
|
+
("It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of "
|
|
40
|
+
"a wife. However little known the feelings or views of such a man may be on his first entering a neighbourhood, "
|
|
41
|
+
"this truth is so well fixed in the minds of the surrounding families, that he is considered the rightful "
|
|
42
|
+
"property of some one or other of their daughters.", "Jane Austen, Pride and Prejudice"),
|
|
43
|
+
("Call me Ishmael. Some years ago - never mind how long precisely - having little or no money in my purse, and "
|
|
44
|
+
"nothing particular to interest me on shore, I thought I would sail about a little and see the watery part of "
|
|
45
|
+
"the world. It is a way I have of driving off the spleen and regulating the circulation.",
|
|
46
|
+
"Herman Melville, Moby-Dick"),
|
|
47
|
+
("I went to the woods because I wished to live deliberately, to front only the essential facts of life, and see "
|
|
48
|
+
"if I could not learn what it had to teach, and not, when I came to die, discover that I had not lived. I did "
|
|
49
|
+
"not wish to live what was not life, living is so dear; nor did I wish to practise resignation, unless it was "
|
|
50
|
+
"quite necessary.", "Henry David Thoreau, Walden"),
|
|
51
|
+
("Four score and seven years ago our fathers brought forth on this continent, a new nation, conceived in "
|
|
52
|
+
"Liberty, and dedicated to the proposition that all men are created equal. Now we are engaged in a great civil "
|
|
53
|
+
"war, testing whether that nation, or any nation so conceived and so dedicated, can long endure.",
|
|
54
|
+
"Abraham Lincoln, the Gettysburg Address"),
|
|
55
|
+
("It is interesting to contemplate an entangled bank, clothed with many plants of many kinds, with birds "
|
|
56
|
+
"singing on the bushes, with various insects flitting about, and with worms crawling through the damp earth, "
|
|
57
|
+
"and to reflect that these elaborately constructed forms, so different from each other, and dependent on each "
|
|
58
|
+
"other in so complex a manner, have all been produced by laws acting around us.",
|
|
59
|
+
"Charles Darwin, On the Origin of Species"),
|
|
60
|
+
("I consider that a man's brain originally is like a little empty attic, and you have to stock it with such "
|
|
61
|
+
"furniture as you choose. A fool takes in all the lumber of every sort that he comes across, so that the "
|
|
62
|
+
"knowledge which might be useful to him gets crowded out, or at best is jumbled up with a lot of other things "
|
|
63
|
+
"so that he has a difficulty in laying his hands upon it.", "Arthur Conan Doyle, A Study in Scarlet"),
|
|
64
|
+
("Tom said to himself that it was not such a hollow world, after all. He had discovered a great law of human "
|
|
65
|
+
"action, without knowing it - namely, that in order to make a man or a boy covet a thing, it is only necessary "
|
|
66
|
+
"to make the thing difficult to attain.", "Mark Twain, The Adventures of Tom Sawyer"),
|
|
67
|
+
("Alice was beginning to get very tired of sitting by her sister on the bank, and of having nothing to do: once "
|
|
68
|
+
"or twice she had peeped into the book her sister was reading, but it had no pictures or conversations in it, "
|
|
69
|
+
"'and what is the use of a book,' thought Alice 'without pictures or conversations?'",
|
|
70
|
+
"Lewis Carroll, Alice's Adventures in Wonderland"),
|
|
71
|
+
("Climb the mountains and get their good tidings. Nature's peace will flow into you as sunshine flows into "
|
|
72
|
+
"trees. The winds will blow their own freshness into you, and the storms their energy, while cares will drop "
|
|
73
|
+
"off like autumn leaves.", "John Muir, Our National Parks"),
|
|
74
|
+
("Trust thyself: every heart vibrates to that iron string. Accept the place the divine providence has found for "
|
|
75
|
+
"you, the society of your contemporaries, the connection of events. Great men have always done so, and "
|
|
76
|
+
"confided themselves childlike to the genius of their age.", "Ralph Waldo Emerson, Self-Reliance"),
|
|
77
|
+
("You will rejoice to hear that no disaster has accompanied the commencement of an enterprise which you have "
|
|
78
|
+
"regarded with such evil forebodings. I arrived here yesterday, and my first task is to assure my dear sister "
|
|
79
|
+
"of my welfare and increasing confidence in the success of my undertaking.", "Mary Shelley, Frankenstein"),
|
|
80
|
+
("Buck did not read the newspapers, or he would have known that trouble was brewing, not alone for himself, but "
|
|
81
|
+
"for every tide-water dog, strong of muscle and with warm, long hair, from Puget Sound to San Diego.",
|
|
82
|
+
"Jack London, The Call of the Wild"),
|
|
83
|
+
("Marley was dead: to begin with. There is no doubt whatever about that. The register of his burial was signed "
|
|
84
|
+
"by the clergyman, the clerk, the undertaker, and the chief mourner. Scrooge signed it: and Scrooge's name was "
|
|
85
|
+
"good upon 'Change, for anything he chose to put his hand to.", "Charles Dickens, A Christmas Carol"),
|
|
86
|
+
("In my younger and more vulnerable years my father gave me some advice that I've been turning over in my mind "
|
|
87
|
+
"ever since. 'Whenever you feel like criticizing any one,' he told me, 'just remember that all the people in "
|
|
88
|
+
"this world haven't had the advantages that you've had.'", "F. Scott Fitzgerald, The Great Gatsby"),
|
|
89
|
+
("Happy families are all alike; every unhappy family is unhappy in its own way. Everything was in confusion in "
|
|
90
|
+
"the Oblonskys' house. The wife had discovered that the husband was carrying on an intrigue with a French girl, "
|
|
91
|
+
"who had been a governess in their family, and she had announced to her husband that she could not go on "
|
|
92
|
+
"living in the same house with him.", "Leo Tolstoy, Anna Karenina"),
|
|
93
|
+
("There was no possibility of taking a walk that day. We had been wandering, indeed, in the leafless shrubbery "
|
|
94
|
+
"an hour in the morning; but since dinner (Mrs. Reed, when there was no company, dined early) the cold winter "
|
|
95
|
+
"wind had brought with it clouds so sombre, and a rain so penetrating, that further out-door exercise was now "
|
|
96
|
+
"out of the question.", "Charlotte Bronte, Jane Eyre"),
|
|
97
|
+
("It is not the critic who counts; not the man who points out how the strong man stumbles, or where the doer of "
|
|
98
|
+
"deeds could have done them better. The credit belongs to the man who is actually in the arena, whose face is "
|
|
99
|
+
"marred by dust and sweat and blood; who strives valiantly; who errs, who comes short again and again.",
|
|
100
|
+
"Theodore Roosevelt, Citizenship in a Republic"),
|
|
101
|
+
("No one would have believed in the last years of the nineteenth century that this world was being watched "
|
|
102
|
+
"keenly and closely by intelligences greater than man's and yet as mortal as his own; that as men busied "
|
|
103
|
+
"themselves about their various concerns they were scrutinised and studied, perhaps almost as narrowly as a man "
|
|
104
|
+
"with a microscope might scrutinise the transient creatures that swarm and multiply in a drop of water.",
|
|
105
|
+
"H. G. Wells, The War of the Worlds"),
|
|
106
|
+
("The artist is the creator of beautiful things. To reveal art and conceal the artist is art's aim. The critic "
|
|
107
|
+
"is he who can translate into another manner or a new material his impression of beautiful things.",
|
|
108
|
+
"Oscar Wilde, The Picture of Dorian Gray"),
|
|
109
|
+
("Whenever I find myself growing grim about the mouth; whenever it is a damp, drizzly November in my soul; "
|
|
110
|
+
"whenever I find myself involuntarily pausing before coffin warehouses, and bringing up the rear of every "
|
|
111
|
+
"funeral I meet; then, I account it high time to get to sea as soon as I can.", "Herman Melville, Moby-Dick"),
|
|
112
|
+
]
|
|
113
|
+
|
|
114
|
+
def typable(text):
|
|
115
|
+
"""Plain keyboard version of a quote, or None if it still has odd characters."""
|
|
116
|
+
for a, b in (("\u2019", "'"), ("\u2018", "'"), ("\u201c", '"'), ("\u201d", '"'), ("\u2014", " - "),
|
|
117
|
+
("\u2013", "-"), ("\u2026", "..."), ("\u00a0", " ")):
|
|
118
|
+
text = text.replace(a, b)
|
|
119
|
+
text = " ".join(text.split())
|
|
120
|
+
return text if text.isascii() and text.isprintable() and len(text) >= 10 else None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def all_quotes():
|
|
124
|
+
"""[(id, text, source)]: the bundled collection, else the built-ins."""
|
|
125
|
+
try:
|
|
126
|
+
from importlib.resources import files
|
|
127
|
+
return [tuple(q) for q in json.loads(files("typerace").joinpath("quotes.json").read_text("utf-8"))]
|
|
128
|
+
except Exception:
|
|
129
|
+
return [(f"b{i}", t, src) for i, (t, src) in enumerate(QUOTES)]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Picker:
|
|
133
|
+
"""Random quotes of a random length, never repeating one until every quote
|
|
134
|
+
of that length has been done."""
|
|
135
|
+
|
|
136
|
+
def __init__(self, quotes, only=None):
|
|
137
|
+
self.groups = {name: [q for q in quotes if lo <= len(q[1]) <= hi]
|
|
138
|
+
for name, (lo, hi, _) in LENGTHS.items() if only in (None, name)}
|
|
139
|
+
self.groups = {k: v for k, v in self.groups.items() if v}
|
|
140
|
+
try:
|
|
141
|
+
self.seen = set(json.load(open(SEEN_FILE)))
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
self.seen = set()
|
|
144
|
+
|
|
145
|
+
def next(self):
|
|
146
|
+
names = list(self.groups)
|
|
147
|
+
name = random.choices(names, [LENGTHS[n][2] for n in names])[0]
|
|
148
|
+
group = self.groups[name]
|
|
149
|
+
fresh = [q for q in group if q[0] not in self.seen]
|
|
150
|
+
if not fresh: # done them all: start this length over
|
|
151
|
+
self.seen -= {q[0] for q in group}
|
|
152
|
+
fresh = group
|
|
153
|
+
q = random.choice(fresh)
|
|
154
|
+
self.seen.add(q[0])
|
|
155
|
+
os.makedirs(DATA, exist_ok=True)
|
|
156
|
+
with open(SEEN_FILE, "w") as f:
|
|
157
|
+
json.dump(sorted(self.seen), f)
|
|
158
|
+
return q[1], q[2] or "unknown"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# colour pairs
|
|
162
|
+
OK, BAD, BADBG, DIM, ACC, TXT, CUR = range(1, 8)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def wpm(chars, secs):
|
|
166
|
+
return chars / 5 / (secs / 60) if secs > 0 else 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def save(speed, acc, secs, source):
|
|
170
|
+
os.makedirs(os.path.dirname(HISTORY), exist_ok=True)
|
|
171
|
+
new = not os.path.exists(HISTORY)
|
|
172
|
+
with open(HISTORY, "a", newline="") as f:
|
|
173
|
+
w = csv.writer(f)
|
|
174
|
+
if new:
|
|
175
|
+
w.writerow(["date", "wpm", "accuracy", "seconds", "quote_by"])
|
|
176
|
+
w.writerow([dt.datetime.now().isoformat(timespec="seconds"), f"{speed:.1f}", f"{acc:.1f}", f"{secs:.1f}", source])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def history():
|
|
180
|
+
try:
|
|
181
|
+
with open(HISTORY) as f:
|
|
182
|
+
return list(csv.DictReader(f))
|
|
183
|
+
except OSError:
|
|
184
|
+
return []
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def put(scr, y, x, s, attr=0):
|
|
188
|
+
try:
|
|
189
|
+
scr.addstr(y, x, s, attr)
|
|
190
|
+
except curses.error:
|
|
191
|
+
pass
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def box(scr, y, x, h, w, attr):
|
|
195
|
+
put(scr, y, x, "╭" + "─" * (w - 2) + "╮", attr)
|
|
196
|
+
for i in range(1, h - 1):
|
|
197
|
+
put(scr, y + i, x, "│", attr)
|
|
198
|
+
put(scr, y + i, x + w - 1, "│", attr)
|
|
199
|
+
put(scr, y + h - 1, x, "╰" + "─" * (w - 2) + "╯", attr)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def read_key(scr):
|
|
203
|
+
"""A key, with Alt+Backspace folded into 'delete word'."""
|
|
204
|
+
k = scr.get_wch()
|
|
205
|
+
if k == "\x1b":
|
|
206
|
+
scr.nodelay(True)
|
|
207
|
+
try:
|
|
208
|
+
nxt = scr.get_wch()
|
|
209
|
+
except curses.error:
|
|
210
|
+
nxt = None
|
|
211
|
+
scr.nodelay(False)
|
|
212
|
+
if nxt in ("\x7f", curses.KEY_BACKSPACE, "\x08"):
|
|
213
|
+
return "DELWORD"
|
|
214
|
+
if nxt is not None:
|
|
215
|
+
return None # some other escape sequence: ignore
|
|
216
|
+
return "ESC"
|
|
217
|
+
if k in ("\x08", "\x17"): # Ctrl+Backspace, Ctrl+W
|
|
218
|
+
return "DELWORD"
|
|
219
|
+
if k == curses.KEY_BACKSPACE:
|
|
220
|
+
# curses turns the terminal's "backspace" code into KEY_BACKSPACE. Where
|
|
221
|
+
# that code is ^H (macOS terminfo), the Backspace key itself sends DEL
|
|
222
|
+
# and ^H means Ctrl+Backspace.
|
|
223
|
+
return "DELWORD" if curses.tigetstr("kbs") == b"\x08" else "BACK"
|
|
224
|
+
if k == "\x7f":
|
|
225
|
+
return "BACK"
|
|
226
|
+
return k
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def race(scr, text, source):
|
|
230
|
+
"""One race. Returns (wpm, accuracy, seconds), 'next' (Tab) or None (Esc)."""
|
|
231
|
+
words = text.split(" ")
|
|
232
|
+
targets = [w + " " for w in words[:-1]] + [words[-1]]
|
|
233
|
+
starts = [sum(len(t) for t in targets[:i]) for i in range(len(targets))]
|
|
234
|
+
wi, cur = 0, "" # current word index, input box contents
|
|
235
|
+
start = None
|
|
236
|
+
keystrokes = errors = 0
|
|
237
|
+
log, typos = [(0.0, 0)], [] # (seconds, characters done) per word; typo times
|
|
238
|
+
scr.timeout(200) # redraw a few times a second for the live WPM
|
|
239
|
+
while True:
|
|
240
|
+
h, w = scr.getmaxyx()
|
|
241
|
+
width = max(30, min(w - 4, 76))
|
|
242
|
+
left = max(0, (w - width) // 2)
|
|
243
|
+
inner = width - 4
|
|
244
|
+
lines = textwrap.wrap(text, inner) or [text]
|
|
245
|
+
top = max(0, (h - (len(lines) + 13)) // 2)
|
|
246
|
+
|
|
247
|
+
target = targets[wi] if wi < len(targets) else ""
|
|
248
|
+
good = 0 # correct prefix of the input box
|
|
249
|
+
while good < len(cur) and good < len(target) and cur[good] == target[good]:
|
|
250
|
+
good += 1
|
|
251
|
+
wrong = len(cur) > good
|
|
252
|
+
done_chars = starts[wi] + good if wi < len(targets) else len(text)
|
|
253
|
+
elapsed = time.time() - start if start else 0
|
|
254
|
+
speed = wpm(done_chars, elapsed)
|
|
255
|
+
acc = 100 * (keystrokes - errors) / keystrokes if keystrokes else 100
|
|
256
|
+
|
|
257
|
+
scr.erase()
|
|
258
|
+
# --- race lane --------------------------------------------------------------
|
|
259
|
+
box(scr, top, left, 4, width, curses.color_pair(DIM))
|
|
260
|
+
put(scr, top, left + 2, " race ", curses.color_pair(DIM))
|
|
261
|
+
lane = inner - 12
|
|
262
|
+
pos = int(lane * done_chars / len(text))
|
|
263
|
+
put(scr, top + 1, left + 2, "you", curses.color_pair(TXT) | curses.A_BOLD)
|
|
264
|
+
put(scr, top + 2, left + 2, "┄" * lane, curses.color_pair(DIM))
|
|
265
|
+
put(scr, top + 2, left + 2 + lane, "┃", curses.color_pair(DIM))
|
|
266
|
+
put(scr, top + 2, left + 2 + min(pos, lane - 1), CAR + " ", curses.color_pair(ACC) | curses.A_BOLD)
|
|
267
|
+
put(scr, top + 2, left + width - 10, f"{speed:4.0f} wpm", curses.color_pair(ACC) | curses.A_BOLD)
|
|
268
|
+
|
|
269
|
+
# --- passage: done words green, current word underlined, mistakes red -------
|
|
270
|
+
ptop = top + 5
|
|
271
|
+
box(scr, ptop, left, len(lines) + 2, width, curses.color_pair(DIM))
|
|
272
|
+
i = 0
|
|
273
|
+
cur_start = starts[wi] if wi < len(targets) else len(text)
|
|
274
|
+
cur_end = cur_start + len(words[wi]) if wi < len(words) else cur_start
|
|
275
|
+
for row, line in enumerate(lines):
|
|
276
|
+
for col, ch in enumerate(line):
|
|
277
|
+
# textwrap drops the spaces it wraps at; keep i in step with text
|
|
278
|
+
while i < len(text) and text[i] != ch:
|
|
279
|
+
i += 1
|
|
280
|
+
if i < cur_start:
|
|
281
|
+
attr = curses.color_pair(OK)
|
|
282
|
+
elif i < cur_start + good:
|
|
283
|
+
attr = curses.color_pair(OK) | curses.A_UNDERLINE
|
|
284
|
+
elif i < cur_start + len(cur):
|
|
285
|
+
attr = curses.color_pair(BADBG)
|
|
286
|
+
elif i < cur_end:
|
|
287
|
+
attr = curses.color_pair(TXT) | curses.A_UNDERLINE
|
|
288
|
+
else:
|
|
289
|
+
attr = curses.color_pair(TXT)
|
|
290
|
+
put(scr, ptop + 1 + row, left + 2 + col, ch, attr)
|
|
291
|
+
i += 1
|
|
292
|
+
|
|
293
|
+
# --- input box ---------------------------------------------------------------
|
|
294
|
+
itop = ptop + len(lines) + 3
|
|
295
|
+
box(scr, itop, left, 3, width, curses.color_pair(BAD if wrong else ACC))
|
|
296
|
+
shown = cur[-(inner - 1):]
|
|
297
|
+
put(scr, itop + 1, left + 2, shown, curses.color_pair(BAD if wrong else TXT))
|
|
298
|
+
put(scr, itop + 1, left + 2 + len(shown), " ", curses.color_pair(CUR) | curses.A_REVERSE)
|
|
299
|
+
if start is None:
|
|
300
|
+
put(scr, itop + 1, left + 4, "type the text above when you're ready", curses.color_pair(DIM))
|
|
301
|
+
put(scr, itop + 3, left + 2, f"{acc:.0f}% accuracy · {elapsed:.0f}s", curses.color_pair(DIM))
|
|
302
|
+
hint = "ctrl+backspace clear word · tab new text · esc quit"
|
|
303
|
+
put(scr, itop + 3, left + width - 2 - len(hint), hint, curses.color_pair(DIM))
|
|
304
|
+
scr.refresh()
|
|
305
|
+
|
|
306
|
+
if wi == len(targets):
|
|
307
|
+
return speed, acc, elapsed, log, typos
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
key = read_key(scr)
|
|
311
|
+
except curses.error:
|
|
312
|
+
continue # timeout: just redraw
|
|
313
|
+
if key is None:
|
|
314
|
+
continue
|
|
315
|
+
if key == "ESC":
|
|
316
|
+
return None
|
|
317
|
+
if key == "\t":
|
|
318
|
+
return "next"
|
|
319
|
+
if key == "BACK":
|
|
320
|
+
cur = cur[:-1]
|
|
321
|
+
elif key == "DELWORD":
|
|
322
|
+
cur = ""
|
|
323
|
+
elif isinstance(key, str) and key.isprintable() and len(cur) < len(target) + 12:
|
|
324
|
+
if start is None:
|
|
325
|
+
start = time.time()
|
|
326
|
+
keystrokes += 1
|
|
327
|
+
cur += key
|
|
328
|
+
if not target.startswith(cur):
|
|
329
|
+
errors += 1
|
|
330
|
+
typos.append(time.time() - start)
|
|
331
|
+
if cur == target: # word done: lock it in and clear the box
|
|
332
|
+
log.append((time.time() - start, starts[wi] + len(target)))
|
|
333
|
+
wi += 1
|
|
334
|
+
cur = ""
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def chars_at(log, t):
|
|
338
|
+
"""Characters typed by time t, interpolated between finished words."""
|
|
339
|
+
for (t0, c0), (t1, c1) in zip(log, log[1:]):
|
|
340
|
+
if t <= t1:
|
|
341
|
+
return c0 + (c1 - c0) * (t - t0) / (t1 - t0) if t1 > t0 else c1
|
|
342
|
+
return log[-1][1]
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def speed_curve(log, secs, n):
|
|
346
|
+
"""n samples of speed (wpm) over the race, each averaged over a short window."""
|
|
347
|
+
win = max(2.0, secs / 12)
|
|
348
|
+
out = []
|
|
349
|
+
for i in range(n):
|
|
350
|
+
t = secs * (i + 1) / n
|
|
351
|
+
w = min(win, t)
|
|
352
|
+
out.append((chars_at(log, t) - chars_at(log, t - w)) / 5 / (w / 60) if w > 0 else 0)
|
|
353
|
+
return out
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def braille_plot(scr, y, x, rows, cols, values, vmax, attr):
|
|
357
|
+
"""Line chart of `values` (2 per column) in braille dots."""
|
|
358
|
+
bits = ((0x01, 0x02, 0x04, 0x40), (0x08, 0x10, 0x20, 0x80))
|
|
359
|
+
cells = {}
|
|
360
|
+
h = rows * 4 - 1
|
|
361
|
+
prev = None
|
|
362
|
+
for i, v in enumerate(values[:cols * 2]):
|
|
363
|
+
dy = round((1 - min(v, vmax) / vmax) * h) if vmax else h
|
|
364
|
+
lo, hi = (dy, dy) if prev is None else (min(prev, dy), max(prev, dy))
|
|
365
|
+
for yy in range(lo, hi + 1): # join to the previous point
|
|
366
|
+
key = (yy // 4, i // 2)
|
|
367
|
+
cells[key] = cells.get(key, 0) | bits[i % 2][yy % 4]
|
|
368
|
+
prev = dy
|
|
369
|
+
for (r, c), b in cells.items():
|
|
370
|
+
put(scr, y + r, x + c, chr(0x2800 + b), attr)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def best_stretch(log, secs):
|
|
374
|
+
"""Fastest sustained speed: the best wpm over any stretch of at least 3 s
|
|
375
|
+
(or 15% of the race), so two words finished at nearly the same moment
|
|
376
|
+
don't count as a burst."""
|
|
377
|
+
span = min(secs, max(3.0, secs * 0.15))
|
|
378
|
+
best = 0.0
|
|
379
|
+
for i, (t0, c0) in enumerate(log):
|
|
380
|
+
for t1, c1 in log[i + 1:]:
|
|
381
|
+
if t1 - t0 >= span - 1e-9:
|
|
382
|
+
best = max(best, (c1 - c0) / 5 / ((t1 - t0) / 60))
|
|
383
|
+
break
|
|
384
|
+
return best, span
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
AOS = (5, 12, 25, 50, 100)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def ao(values, n):
|
|
391
|
+
"""Speedcubing-style average of n (cstimer convention): drop the fastest
|
|
392
|
+
and slowest 5% (rounded up; 1 each for ao5/ao12, 2 for ao25, 3 for ao50,
|
|
393
|
+
5 for ao100) and average the rest. None if there aren't n results."""
|
|
394
|
+
if len(values) < n:
|
|
395
|
+
return None
|
|
396
|
+
k = -(-n * 5 // 100)
|
|
397
|
+
kept = sorted(values[-n:])[k:n - k]
|
|
398
|
+
return sum(kept) / len(kept)
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def best_ao(values, n):
|
|
402
|
+
found = [a for i in range(n, len(values) + 1) if (a := ao(values[:i], n)) is not None]
|
|
403
|
+
return max(found, default=None)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def results(scr, speed, acc, secs, log, typos, source):
|
|
407
|
+
past = history()
|
|
408
|
+
best = max((float(r["wpm"]) for r in past), default=0)
|
|
409
|
+
save(speed, acc, secs, source)
|
|
410
|
+
speeds = [float(r["wpm"]) for r in past] + [speed]
|
|
411
|
+
|
|
412
|
+
def draw():
|
|
413
|
+
# drawn again whenever the window is resized (e.g. a new i3 tab or split)
|
|
414
|
+
h, w = scr.getmaxyx()
|
|
415
|
+
width = min(w - 4, 76)
|
|
416
|
+
left = max(0, (w - width) // 2)
|
|
417
|
+
chart_rows = max(0, min(8, h - 29))
|
|
418
|
+
height = 18 + (chart_rows + 5 if chart_rows >= 3 else 0)
|
|
419
|
+
top = max(0, (h - height - 2) // 2)
|
|
420
|
+
scr.erase()
|
|
421
|
+
box(scr, top, left, height, width, curses.color_pair(ACC))
|
|
422
|
+
put(scr, top, left + 2, " race results ", curses.color_pair(ACC) | curses.A_BOLD)
|
|
423
|
+
|
|
424
|
+
# pace through the race: speed in each quarter of the time
|
|
425
|
+
q = [(chars_at(log, secs * (i + 1) / 4) - chars_at(log, secs * i / 4)) / 5 / (secs / 4 / 60) for i in range(4)]
|
|
426
|
+
curve = speed_curve(log, secs, max(8, (width - 12) * 2))
|
|
427
|
+
peak, span = best_stretch(log, secs)
|
|
428
|
+
change = q[3] - q[0]
|
|
429
|
+
rows = [
|
|
430
|
+
("speed", f"{speed:.0f} wpm", curses.color_pair(ACC) | curses.A_BOLD),
|
|
431
|
+
("accuracy", f"{acc:.1f}% ({len(typos)} typo{'s' if len(typos) != 1 else ''})", curses.color_pair(TXT)),
|
|
432
|
+
("time", f"{secs:.1f}s", curses.color_pair(TXT)),
|
|
433
|
+
("peak", f"{peak:.0f} wpm (best {span:.0f}s stretch)", curses.color_pair(TXT)),
|
|
434
|
+
("pace", f"{q[0]:.0f} > {(q[1] + q[2]) / 2:.0f} > {q[3]:.0f} wpm ({'+' if change >= 0 else ''}{change:.0f} start to finish)",
|
|
435
|
+
curses.color_pair(OK if change >= 0 else BAD)),
|
|
436
|
+
]
|
|
437
|
+
for i, (k, v, attr) in enumerate(rows):
|
|
438
|
+
put(scr, top + 2 + i, left + 4, f"{k:<10}", curses.color_pair(DIM))
|
|
439
|
+
put(scr, top + 2 + i, left + 16, v[:width - 20], attr)
|
|
440
|
+
if speed > best:
|
|
441
|
+
put(scr, top + 8, left + 4, "new personal best!", curses.color_pair(OK) | curses.A_BOLD)
|
|
442
|
+
else:
|
|
443
|
+
put(scr, top + 8, left + 4, f"personal best {best:.0f} wpm", curses.color_pair(DIM))
|
|
444
|
+
|
|
445
|
+
# averages, speedcubing style: current (ending with this race) and best ever
|
|
446
|
+
col = 8
|
|
447
|
+
put(scr, top + 10, left + 16, "single".rjust(col - 2) + "".join(f"ao{n}".rjust(col) for n in AOS),
|
|
448
|
+
curses.color_pair(DIM))
|
|
449
|
+
put(scr, top + 11, left + 4, "current", curses.color_pair(DIM))
|
|
450
|
+
put(scr, top + 12, left + 4, "best", curses.color_pair(DIM))
|
|
451
|
+
put(scr, top + 11, left + 16, f"{speed:.0f}".rjust(col - 2), curses.color_pair(TXT))
|
|
452
|
+
put(scr, top + 12, left + 16, f"{max(speeds):.0f}".rjust(col - 2), curses.color_pair(TXT))
|
|
453
|
+
for i, n in enumerate(AOS):
|
|
454
|
+
x = left + 16 + col - 2 + i * col
|
|
455
|
+
cur, prev_best, best_n = ao(speeds, n), best_ao(speeds[:-1], n), best_ao(speeds, n)
|
|
456
|
+
new_best = cur is not None and (prev_best is None or cur > prev_best)
|
|
457
|
+
put(scr, top + 11, x, (f"{cur:.1f}" if cur is not None else "-").rjust(col),
|
|
458
|
+
curses.color_pair(OK) | curses.A_BOLD if new_best else curses.color_pair(TXT))
|
|
459
|
+
put(scr, top + 12, x, (f"{best_n:.1f}" if best_n is not None else "-").rjust(col), curses.color_pair(TXT))
|
|
460
|
+
|
|
461
|
+
# the last 12 races, newest first, cstimer style: the ones ao12 drops in
|
|
462
|
+
# brackets, this race highlighted
|
|
463
|
+
last = speeds[-12:]
|
|
464
|
+
drop = set()
|
|
465
|
+
if len(last) == 12:
|
|
466
|
+
order = sorted(range(12), key=lambda i: last[i])
|
|
467
|
+
drop = {order[0], order[-1]}
|
|
468
|
+
total = f"{len(speeds)} race{'s' if len(speeds) != 1 else ''} total"
|
|
469
|
+
put(scr, top + 14, left + 4, f"last {len(last)} races", curses.color_pair(DIM))
|
|
470
|
+
put(scr, top + 14, left + width - 4 - len(total), total, curses.color_pair(DIM))
|
|
471
|
+
step = max(5, min(6, (width - 9) // 12)) # column spacing that fits 12
|
|
472
|
+
for slot, i in enumerate(reversed(range(len(last)))): # newest on the left
|
|
473
|
+
v = last[i]
|
|
474
|
+
text = f"({v:.0f})" if i in drop else f"{v:.0f}"
|
|
475
|
+
attr = curses.color_pair(ACC) | curses.A_BOLD if i == len(last) - 1 else \
|
|
476
|
+
curses.color_pair(DIM) if i in drop else curses.color_pair(TXT)
|
|
477
|
+
x = left + 4 + slot * step
|
|
478
|
+
if x + 6 <= left + width - 1:
|
|
479
|
+
put(scr, top + 15, x + 5 - len(text) + (1 if i in drop else 0), text, attr)
|
|
480
|
+
|
|
481
|
+
# chart: speed over the race, the final average as a dashed line, typos below
|
|
482
|
+
if chart_rows >= 3:
|
|
483
|
+
cy, cx = top + 17, left + 9
|
|
484
|
+
cols = width - 12
|
|
485
|
+
vmax = max(20, int(max(curve + [speed]) * 1.15 / 10 + 1) * 10)
|
|
486
|
+
put(scr, cy, left + 2, "speed over the race", curses.color_pair(DIM))
|
|
487
|
+
cy += 1
|
|
488
|
+
avg_row = round((1 - speed / vmax) * (chart_rows * 4 - 1)) // 4
|
|
489
|
+
for c in range(cols):
|
|
490
|
+
put(scr, cy + avg_row, cx + c, "╌", curses.color_pair(DIM))
|
|
491
|
+
braille_plot(scr, cy, cx, chart_rows, cols, curve, vmax, curses.color_pair(ACC))
|
|
492
|
+
for r, label in ((0, vmax), (chart_rows // 2, vmax // 2), (chart_rows - 1, 0)):
|
|
493
|
+
put(scr, cy + r, left + 2, f"{label:>5} ", curses.color_pair(DIM))
|
|
494
|
+
put(scr, cy + chart_rows, cx, "└" + "─" * (cols - 1), curses.color_pair(DIM))
|
|
495
|
+
for t in typos: # typo ticks along the time axis
|
|
496
|
+
put(scr, cy + chart_rows, cx + min(cols - 1, int(t / secs * cols)), "▴",
|
|
497
|
+
curses.color_pair(BAD) | curses.A_BOLD)
|
|
498
|
+
put(scr, cy + chart_rows + 1, cx, "0s", curses.color_pair(DIM))
|
|
499
|
+
put(scr, cy + chart_rows + 1, cx + cols - len(f"{secs:.0f}s"), f"{secs:.0f}s", curses.color_pair(DIM))
|
|
500
|
+
legend = "⠒ speed ╌ average ▴ typo"
|
|
501
|
+
put(scr, cy + chart_rows + 1, cx + (cols - len(legend)) // 2, legend, curses.color_pair(DIM))
|
|
502
|
+
|
|
503
|
+
put(scr, top + height - 1, left + 2, f" {source[:width - 8]} ", curses.color_pair(TXT) | curses.A_ITALIC)
|
|
504
|
+
put(scr, top + height + 1, left + 2, "enter race again · q / esc quit", curses.color_pair(DIM))
|
|
505
|
+
scr.refresh()
|
|
506
|
+
scr.timeout(250) # wake up regularly so resizes are noticed
|
|
507
|
+
draw()
|
|
508
|
+
while True:
|
|
509
|
+
try:
|
|
510
|
+
k = read_key(scr)
|
|
511
|
+
except curses.error:
|
|
512
|
+
continue
|
|
513
|
+
if k == curses.KEY_RESIZE:
|
|
514
|
+
draw()
|
|
515
|
+
continue
|
|
516
|
+
if k in ("\n", "\t", " ", curses.KEY_ENTER):
|
|
517
|
+
return True
|
|
518
|
+
if k in ("ESC", "q", "Q"):
|
|
519
|
+
return False
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def main(scr, picker):
|
|
523
|
+
curses.curs_set(0)
|
|
524
|
+
curses.use_default_colors()
|
|
525
|
+
curses.set_escdelay(25)
|
|
526
|
+
dim = 8 if curses.COLORS > 8 else curses.COLOR_WHITE
|
|
527
|
+
curses.init_pair(OK, curses.COLOR_GREEN, -1)
|
|
528
|
+
curses.init_pair(BAD, curses.COLOR_RED, -1)
|
|
529
|
+
curses.init_pair(BADBG, curses.COLOR_BLACK, curses.COLOR_RED)
|
|
530
|
+
curses.init_pair(DIM, dim, -1)
|
|
531
|
+
curses.init_pair(ACC, curses.COLOR_CYAN, -1)
|
|
532
|
+
curses.init_pair(TXT, curses.COLOR_WHITE, -1)
|
|
533
|
+
curses.init_pair(CUR, curses.COLOR_CYAN, -1)
|
|
534
|
+
while True:
|
|
535
|
+
quote = picker.next()
|
|
536
|
+
r = race(scr, *quote)
|
|
537
|
+
if r is None:
|
|
538
|
+
return
|
|
539
|
+
if r == "next":
|
|
540
|
+
continue
|
|
541
|
+
if not results(scr, *r, quote[1]):
|
|
542
|
+
return
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def show_history():
|
|
546
|
+
rows = history()
|
|
547
|
+
if not rows:
|
|
548
|
+
print("no races yet")
|
|
549
|
+
return 0
|
|
550
|
+
for r in rows[-15:]:
|
|
551
|
+
print(f"{r['date'].replace('T', ' ')} {float(r['wpm']):5.0f} wpm {float(r['accuracy']):5.1f}% {r['quote_by']}")
|
|
552
|
+
speeds = [float(r["wpm"]) for r in rows]
|
|
553
|
+
aos = " ".join(f"ao{n} {a:.1f}" for n in AOS if (a := ao(speeds, n)) is not None)
|
|
554
|
+
print(f"\n{len(rows)} races, best {max(speeds):.0f} wpm" + (f" · {aos}" if aos else ""))
|
|
555
|
+
return 0
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def cli(argv=None):
|
|
559
|
+
p = argparse.ArgumentParser(
|
|
560
|
+
prog="typerace",
|
|
561
|
+
description="TypeRacer-style typing races in the terminal.",
|
|
562
|
+
epilog="keys: ctrl+backspace / ctrl+w clear the word, tab new quote, esc quit. "
|
|
563
|
+
"Set TYPERACE_NERD_FONT=1 for a car icon if your terminal uses a Nerd Font.")
|
|
564
|
+
p.add_argument("length", nargs="?", choices=list(LENGTHS),
|
|
565
|
+
help="only race quotes of this length (default: a mix)")
|
|
566
|
+
p.add_argument("--history", action="store_true", help="show recent races and averages")
|
|
567
|
+
p.add_argument("--version", action="version", version=f"typerace {__version__}")
|
|
568
|
+
args = p.parse_args(argv)
|
|
569
|
+
if args.history:
|
|
570
|
+
return show_history()
|
|
571
|
+
picker = Picker(all_quotes(), args.length)
|
|
572
|
+
os.environ.setdefault("ESCDELAY", "25")
|
|
573
|
+
try:
|
|
574
|
+
curses.wrapper(main, picker)
|
|
575
|
+
except KeyboardInterrupt:
|
|
576
|
+
pass
|
|
577
|
+
return 0
|
|
578
|
+
|