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 ADDED
@@ -0,0 +1,3 @@
1
+ """TypeRacer-style typing races in the terminal."""
2
+
3
+ __version__ = "0.1.0"
typerace/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .app import cli
4
+
5
+ sys.exit(cli())
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
+