codetype 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # CodeType
2
+
3
+ Offline terminal code-typing practice game. Pure Python stdlib (curses) — no network, no heavy deps.
4
+
5
+ ## Install & run
6
+
7
+ With [pipx](https://pipx.pypa.io) (recommended — gives you the `codetype` command everywhere):
8
+
9
+ ```sh
10
+ pipx install git+https://github.com/rohan4naik/codetype-cli
11
+ codetype
12
+ ```
13
+
14
+ Or with pip from a clone:
15
+
16
+ ```sh
17
+ git clone https://github.com/rohan4naik/codetype-cli
18
+ cd codetype-cli
19
+ pip install .
20
+ codetype
21
+ ```
22
+
23
+ Or no install at all — run straight from the clone:
24
+
25
+ ```sh
26
+ python3 -m codetype
27
+ ```
28
+
29
+ Windows: `pip install .` pulls in `windows-curses` automatically.
30
+
31
+ ## How it works
32
+
33
+ - Pick a language from the menu (python / javascript / typescript / c / go / rust / java / sql / bash / random).
34
+ - Or pick **sentences (speed test)** — a fresh block of random common words every run, for raw typing speed. Space or Enter both work at line wraps.
35
+ - Type the snippet exactly. Correct chars turn green, mistakes show red and must be backspaced before you can continue.
36
+ - Press Enter at end of line — leading indentation of the next line is auto-skipped (shown dim, not counted toward WPM).
37
+ - Live WPM, accuracy, and timer in the header. Timer starts on your first keystroke.
38
+ - `history` in the menu shows your last 15 runs and per-language bests.
39
+
40
+ ## Keys
41
+
42
+ | Key | Action |
43
+ |---|---|
44
+ | Tab | Restart current snippet |
45
+ | Esc | Back to menu / quit |
46
+ | Backspace | Fix mistake |
47
+ | Enter (results) | Next snippet |
48
+
49
+ ## Stats
50
+
51
+ Every completed run is appended to `~/.codetype/stats.json` (WPM, accuracy, errors, time, date). Results screen shows your per-language best.
52
+
53
+ ## Adding snippets
54
+
55
+ Edit `codetype/snippets.py` — append strings to any language list or add a new language key. Use spaces for indentation, not tabs.
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawnSync } = require("child_process");
5
+ const path = require("path");
6
+
7
+ const pkgRoot = path.resolve(__dirname, "..");
8
+
9
+ function findPython() {
10
+ const candidates =
11
+ process.platform === "win32"
12
+ ? ["python", "python3", "py"]
13
+ : ["python3", "python"];
14
+ for (const cmd of candidates) {
15
+ const check = spawnSync(cmd, ["-c", "import sys; sys.exit(0 if sys.version_info >= (3, 8) else 1)"], {
16
+ stdio: "ignore",
17
+ });
18
+ if (check.status === 0) return cmd;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ const python = findPython();
24
+ if (!python) {
25
+ console.error("codetype requires Python 3.8+ but none was found on your PATH.");
26
+ console.error("Install it from https://www.python.org/downloads/ and try again.");
27
+ process.exit(1);
28
+ }
29
+
30
+ const env = { ...process.env };
31
+ env.PYTHONPATH = env.PYTHONPATH
32
+ ? pkgRoot + path.delimiter + env.PYTHONPATH
33
+ : pkgRoot;
34
+
35
+ const result = spawnSync(python, ["-m", "codetype", ...process.argv.slice(2)], {
36
+ stdio: "inherit",
37
+ env,
38
+ });
39
+
40
+ if (result.error) {
41
+ console.error("Failed to launch Python:", result.error.message);
42
+ process.exit(1);
43
+ }
44
+
45
+ if (process.platform === "win32" && result.status !== 0) {
46
+ console.error("");
47
+ console.error("If you saw a curses import error, run: pip install windows-curses");
48
+ }
49
+
50
+ process.exit(result.status === null ? 1 : result.status);
File without changes
@@ -0,0 +1,3 @@
1
+ from .game import run
2
+
3
+ run()
@@ -0,0 +1,503 @@
1
+ #!/usr/bin/env python3
2
+ """CodeType — offline terminal code-typing practice game.
3
+
4
+ Run: codetype (after pip/pipx install) or python3 -m codetype
5
+
6
+ Keys in-game:
7
+ type the code progress
8
+ Enter newline (leading indent auto-skips)
9
+ Backspace fix mistakes
10
+ Tab restart current snippet
11
+ Esc back to menu
12
+ """
13
+
14
+ import curses
15
+ import json
16
+ import locale
17
+ import os
18
+ import random
19
+ import time
20
+
21
+ locale.setlocale(locale.LC_ALL, "")
22
+
23
+ from .snippets import SNIPPETS, random_sentence_test
24
+
25
+ STATS_PATH = os.path.join(os.path.expanduser("~"), ".codetype", "stats.json")
26
+
27
+ # color pair ids
28
+ C_CORRECT = 1
29
+ C_WRONG = 2
30
+ C_PENDING = 3
31
+ C_CURSOR = 4
32
+ C_TITLE = 5
33
+ C_DIM = 6
34
+
35
+
36
+ def init_colors():
37
+ curses.start_color()
38
+ curses.use_default_colors()
39
+ curses.init_pair(C_CORRECT, curses.COLOR_GREEN, -1)
40
+ curses.init_pair(C_WRONG, curses.COLOR_WHITE, curses.COLOR_RED)
41
+ curses.init_pair(C_PENDING, curses.COLOR_WHITE, -1)
42
+ curses.init_pair(C_CURSOR, curses.COLOR_BLACK, curses.COLOR_YELLOW)
43
+ curses.init_pair(C_TITLE, curses.COLOR_CYAN, -1)
44
+ curses.init_pair(C_DIM, curses.COLOR_WHITE, -1)
45
+
46
+
47
+ def load_stats():
48
+ try:
49
+ with open(STATS_PATH) as f:
50
+ return json.load(f)
51
+ except (FileNotFoundError, json.JSONDecodeError):
52
+ return {"runs": []}
53
+
54
+
55
+ def save_run(lang, wpm, accuracy, chars, seconds):
56
+ stats = load_stats()
57
+ stats["runs"].append({
58
+ "lang": lang,
59
+ "wpm": round(wpm, 1),
60
+ "accuracy": round(accuracy, 1),
61
+ "chars": chars,
62
+ "seconds": round(seconds, 1),
63
+ "when": time.strftime("%Y-%m-%d %H:%M"),
64
+ })
65
+ os.makedirs(os.path.dirname(STATS_PATH), exist_ok=True)
66
+ with open(STATS_PATH, "w") as f:
67
+ json.dump(stats, f, indent=2)
68
+ return stats
69
+
70
+
71
+ def best_wpm(stats, lang):
72
+ runs = [r["wpm"] for r in stats["runs"] if r["lang"] == lang]
73
+ return max(runs) if runs else 0.0
74
+
75
+
76
+ # ANSI-shadow letters for the menu banner
77
+ ANSI_LETTERS = {
78
+ "C": [" ██████╗", "██╔════╝", "██║ ", "██║ ", "╚██████╗", " ╚═════╝"],
79
+ "O": [" ██████╗ ", "██╔═══██╗", "██║ ██║", "██║ ██║", "╚██████╔╝", " ╚═════╝ "],
80
+ "D": ["██████╗ ", "██╔══██╗", "██║ ██║", "██║ ██║", "██████╔╝", "╚═════╝ "],
81
+ "E": ["███████╗", "██╔════╝", "█████╗ ", "██╔══╝ ", "███████╗", "╚══════╝"],
82
+ "T": ["████████╗", "╚══██╔══╝", " ██║ ", " ██║ ", " ██║ ", " ╚═╝ "],
83
+ "Y": ["██╗ ██╗", "╚██╗ ██╔╝", " ╚████╔╝ ", " ╚██╔╝ ", " ██║ ", " ╚═╝ "],
84
+ "P": ["██████╗ ", "██╔══██╗", "██████╔╝", "██╔═══╝ ", "██║ ", "╚═╝ "],
85
+ }
86
+
87
+
88
+ def banner_lines(word="CODETYPE"):
89
+ return ["".join(ANSI_LETTERS[c][row] for c in word) for row in range(6)]
90
+
91
+
92
+ BIG_FONT = {
93
+ "0": ["███", "█ █", "█ █", "█ █", "███"],
94
+ "1": [" █ ", "██ ", " █ ", " █ ", "███"],
95
+ "2": ["███", " █", "███", "█ ", "███"],
96
+ "3": ["███", " █", "███", " █", "███"],
97
+ "4": ["█ █", "█ █", "███", " █", " █"],
98
+ "5": ["███", "█ ", "███", " █", "███"],
99
+ "6": ["███", "█ ", "███", "█ █", "███"],
100
+ "7": ["███", " █", " █", " █", " █"],
101
+ "8": ["███", "█ █", "███", "█ █", "███"],
102
+ "9": ["███", "█ █", "███", " █", "███"],
103
+ ".": [" ", " ", " ", " ", "█ "],
104
+ }
105
+
106
+
107
+ def big_text_width(s):
108
+ return sum(len(BIG_FONT[c][0]) for c in s) + len(s) - 1
109
+
110
+
111
+ def draw_big_text(win, y, x, s, attr=0):
112
+ """Render digits/dot as 5-row block characters."""
113
+ for row in range(5):
114
+ cx = x
115
+ for c in s:
116
+ glyph = BIG_FONT[c][row]
117
+ addstr_safe(win, y + row, cx, glyph, attr)
118
+ cx += len(glyph) + 1
119
+
120
+
121
+ def addstr_safe(win, y, x, text, attr=0):
122
+ """addstr that ignores writes outside the window instead of crashing."""
123
+ h, w = win.getmaxyx()
124
+ if 0 <= y < h and x < w:
125
+ try:
126
+ win.addstr(y, x, text[: max(0, w - x)], attr)
127
+ except curses.error:
128
+ pass
129
+
130
+
131
+ class Menu:
132
+ """Simple arrow-key vertical menu. Returns chosen index, or None on Esc."""
133
+
134
+ def __init__(self, stdscr, title, options, footer="", banner=None, tagline=""):
135
+ self.stdscr = stdscr
136
+ self.title = title
137
+ self.options = options
138
+ self.footer = footer
139
+ self.banner = banner or []
140
+ self.tagline = tagline
141
+
142
+ def run(self):
143
+ idx = 0
144
+ while True:
145
+ self.stdscr.erase()
146
+ h, w = self.stdscr.getmaxyx()
147
+
148
+ bw = max((len(l) for l in self.banner), default=0)
149
+ use_banner = (self.banner and w >= bw + 2
150
+ and h >= len(self.banner) + len(self.options) + 8)
151
+ if use_banner:
152
+ by = max(1, (h - (len(self.banner) + len(self.options) + 5)) // 2)
153
+ for i, line in enumerate(self.banner):
154
+ addstr_safe(self.stdscr, by + i, max(0, (w - bw) // 2), line,
155
+ curses.color_pair(C_TITLE) | curses.A_BOLD)
156
+ if self.tagline:
157
+ addstr_safe(self.stdscr, by + len(self.banner) + 1,
158
+ max(0, (w - len(self.tagline)) // 2),
159
+ self.tagline, curses.A_DIM)
160
+ top = by + len(self.banner) + 3
161
+ else:
162
+ top = max(1, h // 2 - len(self.options) // 2 - 2)
163
+ addstr_safe(self.stdscr, top - 2, max(0, (w - len(self.title)) // 2),
164
+ self.title, curses.color_pair(C_TITLE) | curses.A_BOLD)
165
+
166
+ for i, opt in enumerate(self.options):
167
+ attr = curses.color_pair(C_CURSOR) if i == idx else curses.A_NORMAL
168
+ label = f" {opt} "
169
+ addstr_safe(self.stdscr, top + i, max(0, (w - len(label)) // 2), label, attr)
170
+ if self.footer:
171
+ addstr_safe(self.stdscr, h - 2, max(0, (w - len(self.footer)) // 2),
172
+ self.footer, curses.A_DIM)
173
+ self.stdscr.refresh()
174
+
175
+ key = self.stdscr.getch()
176
+ if key in (curses.KEY_UP, ord("k")):
177
+ idx = (idx - 1) % len(self.options)
178
+ elif key in (curses.KEY_DOWN, ord("j")):
179
+ idx = (idx + 1) % len(self.options)
180
+ elif key in (curses.KEY_ENTER, 10, 13):
181
+ return idx
182
+ elif key == 27: # Esc
183
+ return None
184
+
185
+
186
+ class TypingGame:
187
+ RESULT_QUIT, RESULT_MENU, RESULT_RESTART, RESULT_DONE = range(4)
188
+
189
+ def __init__(self, stdscr, lang, text):
190
+ self.stdscr = stdscr
191
+ self.lang = lang
192
+ self.text = text
193
+ self.lines = text.split("\n")
194
+ # In sentence mode line breaks are soft wraps, so space works as Enter.
195
+ self.space_as_newline = lang == "sentences"
196
+ self.reset()
197
+
198
+ def reset(self):
199
+ self.pos = 0 # index into self.text
200
+ self.wrong = [] # stack of wrongly-typed chars (shown in red)
201
+ self.auto = set() # positions filled by auto-indent (not user keystrokes)
202
+ self.keystrokes = 0 # user keystrokes that produced a char
203
+ self.errors = 0 # keystrokes that were wrong
204
+ self.start_time = None
205
+
206
+ # --- coordinates ---------------------------------------------------
207
+
208
+ def pos_to_rowcol(self, pos):
209
+ """Map an index in self.text to (line, column)."""
210
+ consumed = 0
211
+ for row, line in enumerate(self.lines):
212
+ if pos <= consumed + len(line):
213
+ return row, pos - consumed
214
+ consumed += len(line) + 1 # +1 for the newline
215
+ last = len(self.lines) - 1
216
+ return last, len(self.lines[last])
217
+
218
+ # --- typing logic --------------------------------------------------
219
+
220
+ def skip_auto_indent(self):
221
+ """After a correct newline, auto-fill the next line's leading spaces."""
222
+ while self.pos < len(self.text) and self.text[self.pos] == " ":
223
+ row, col = self.pos_to_rowcol(self.pos)
224
+ stripped = len(self.lines[row]) - len(self.lines[row].lstrip(" "))
225
+ if col >= stripped:
226
+ break
227
+ self.auto.add(self.pos)
228
+ self.pos += 1
229
+
230
+ def handle_key(self, key):
231
+ """Returns True when the snippet is finished."""
232
+ if key in (curses.KEY_BACKSPACE, 127, 8):
233
+ if self.wrong:
234
+ self.wrong.pop()
235
+ elif self.pos > 0:
236
+ self.pos -= 1
237
+ while self.pos > 0 and self.pos in self.auto:
238
+ self.auto.discard(self.pos)
239
+ self.pos -= 1
240
+ self.auto.discard(self.pos)
241
+ return False
242
+
243
+ if key in (curses.KEY_ENTER, 10, 13):
244
+ ch = "\n"
245
+ elif 32 <= key <= 126:
246
+ ch = chr(key)
247
+ else:
248
+ return False # ignore other control keys
249
+
250
+ if self.start_time is None:
251
+ self.start_time = time.time()
252
+ self.keystrokes += 1
253
+
254
+ expected = self.text[self.pos] if self.pos < len(self.text) else None
255
+ matches = ch == expected or (
256
+ self.space_as_newline and expected == "\n" and ch == " "
257
+ )
258
+ if not self.wrong and matches:
259
+ self.pos += 1
260
+ if expected == "\n":
261
+ self.skip_auto_indent()
262
+ else:
263
+ self.errors += 1
264
+ if len(self.wrong) < 10: # cap runaway error trains
265
+ self.wrong.append(ch)
266
+
267
+ return self.pos >= len(self.text) and not self.wrong
268
+
269
+ # --- stats ---------------------------------------------------------
270
+
271
+ def elapsed(self):
272
+ return (time.time() - self.start_time) if self.start_time else 0.0
273
+
274
+ def wpm(self):
275
+ secs = self.elapsed()
276
+ if secs <= 0:
277
+ return 0.0
278
+ typed = self.pos - len([p for p in self.auto if p < self.pos])
279
+ return (typed / 5) / (secs / 60)
280
+
281
+ def accuracy(self):
282
+ if self.keystrokes == 0:
283
+ return 100.0
284
+ return 100.0 * (self.keystrokes - self.errors) / self.keystrokes
285
+
286
+ # --- rendering -----------------------------------------------------
287
+
288
+ def draw(self):
289
+ self.stdscr.erase()
290
+ h, w = self.stdscr.getmaxyx()
291
+
292
+ header = (f" {self.lang} WPM {self.wpm():5.1f} "
293
+ f"ACC {self.accuracy():5.1f}% TIME {self.elapsed():5.1f}s ")
294
+ addstr_safe(self.stdscr, 0, 1, header, curses.color_pair(C_TITLE) | curses.A_BOLD)
295
+
296
+ # Double line-height and bold when the terminal has room, since
297
+ # curses cannot change the actual font size.
298
+ xstep = 1
299
+ ystep = 2 if h >= 14 else 1
300
+
301
+ cur_row, cur_col = self.pos_to_rowcol(self.pos)
302
+ view_h = max(1, (h - 4) // ystep)
303
+ first = max(0, min(cur_row - view_h // 2, len(self.lines) - view_h))
304
+
305
+ consumed = sum(len(l) + 1 for l in self.lines[:first])
306
+ for screen_row, row in enumerate(range(first, min(len(self.lines), first + view_h))):
307
+ line = self.lines[row]
308
+ y = 2 + screen_row * ystep
309
+ for col, ch in enumerate(line):
310
+ p = consumed + col
311
+ if p < self.pos:
312
+ attr = (curses.A_DIM if p in self.auto
313
+ else curses.color_pair(C_CORRECT) | curses.A_BOLD)
314
+ elif p == self.pos and not self.wrong:
315
+ attr = curses.color_pair(C_CURSOR) | curses.A_BOLD
316
+ else:
317
+ attr = curses.A_BOLD
318
+ addstr_safe(self.stdscr, y, 2 + col * xstep, ch, attr)
319
+ # newline cursor marker at end of line
320
+ p = consumed + len(line)
321
+ if p == self.pos and not self.wrong and row < len(self.lines) - 1:
322
+ addstr_safe(self.stdscr, y, 2 + len(line) * xstep, "⏎",
323
+ curses.color_pair(C_CURSOR) | curses.A_BOLD)
324
+ consumed += len(line) + 1
325
+
326
+ if self.wrong:
327
+ wy = 2 + (cur_row - first) * ystep
328
+ for i, c in enumerate(self.wrong):
329
+ shown = "⏎" if c == "\n" else c
330
+ addstr_safe(self.stdscr, wy, 2 + (cur_col + i) * xstep,
331
+ shown, curses.color_pair(C_WRONG) | curses.A_BOLD)
332
+
333
+ addstr_safe(self.stdscr, h - 1, 1,
334
+ "Tab restart Esc menu Backspace fix", curses.A_DIM)
335
+ self.stdscr.refresh()
336
+
337
+ # --- main loop -------------------------------------------------------
338
+
339
+ def run(self):
340
+ self.stdscr.nodelay(True)
341
+ try:
342
+ while True:
343
+ self.draw()
344
+ key = self.stdscr.getch()
345
+ if key == -1:
346
+ time.sleep(0.03)
347
+ continue
348
+ if key == 27:
349
+ return self.RESULT_MENU
350
+ if key == 9: # Tab
351
+ return self.RESULT_RESTART
352
+ if key == curses.KEY_RESIZE:
353
+ continue
354
+ if self.handle_key(key):
355
+ return self.RESULT_DONE
356
+ finally:
357
+ self.stdscr.nodelay(False)
358
+
359
+
360
+ def show_results(stdscr, game):
361
+ secs = game.elapsed()
362
+ wpm = game.wpm()
363
+ acc = game.accuracy()
364
+ stats = save_run(game.lang, wpm, acc, len(game.text), secs)
365
+ best = best_wpm(stats, game.lang)
366
+ is_best = wpm >= best
367
+
368
+ stdscr.erase()
369
+ h, w = stdscr.getmaxyx()
370
+ top = max(1, h // 2 - 9)
371
+
372
+ title = "S N I P P E T C O M P L E T E"
373
+ addstr_safe(stdscr, top, max(0, (w - len(title)) // 2), title,
374
+ curses.color_pair(C_TITLE) | curses.A_BOLD)
375
+
376
+ big = f"{wpm:.1f}"
377
+ bw = big_text_width(big)
378
+ draw_big_text(stdscr, top + 2, max(0, (w - bw) // 2), big,
379
+ curses.color_pair(C_CORRECT) | curses.A_BOLD)
380
+ wpm_label = "words per minute" + (" ★ new best" if is_best else "")
381
+ addstr_safe(stdscr, top + 8, max(0, (w - len(wpm_label)) // 2), wpm_label,
382
+ curses.A_BOLD if is_best else curses.A_DIM)
383
+
384
+ rows = [
385
+ ("Accuracy", f"{acc:.1f} %"),
386
+ ("Errors", f"{game.errors}"),
387
+ ("Time", f"{secs:.1f} s"),
388
+ (f"Best · {game.lang}", f"{best:.1f} wpm"),
389
+ ]
390
+ label_w = max(len(r[0]) for r in rows)
391
+ val_w = max(len(r[1]) for r in rows)
392
+ block_w = label_w + 4 + val_w
393
+ x = max(0, (w - block_w) // 2)
394
+ for i, (label, val) in enumerate(rows):
395
+ addstr_safe(stdscr, top + 10 + i, x, f"{label:<{label_w}}", curses.A_DIM)
396
+ addstr_safe(stdscr, top + 10 + i, x + label_w + 4,
397
+ f"{val:>{val_w}}", curses.A_BOLD)
398
+
399
+ hint = "Enter next Tab retry Esc menu"
400
+ addstr_safe(stdscr, min(h - 1, top + 15), max(0, (w - len(hint)) // 2),
401
+ hint, curses.A_DIM)
402
+ stdscr.refresh()
403
+
404
+ while True:
405
+ key = stdscr.getch()
406
+ if key in (curses.KEY_ENTER, 10, 13):
407
+ return "next"
408
+ if key == 9:
409
+ return "retry"
410
+ if key == 27:
411
+ return "menu"
412
+
413
+
414
+ def show_history(stdscr):
415
+ stats = load_stats()
416
+ runs = stats["runs"][-15:][::-1] # newest first
417
+
418
+ stdscr.erase()
419
+ h, w = stdscr.getmaxyx()
420
+
421
+ title = "H I S T O R Y"
422
+ addstr_safe(stdscr, 1, max(0, (w - len(title)) // 2), title,
423
+ curses.color_pair(C_TITLE) | curses.A_BOLD)
424
+
425
+ if not runs:
426
+ msg = "no runs yet — go type something"
427
+ addstr_safe(stdscr, h // 2, max(0, (w - len(msg)) // 2), msg, curses.A_DIM)
428
+ else:
429
+ header = f"{'WHEN':<17} {'LANG':<11} {'WPM':>6} {'ACC':>7} {'TIME':>7}"
430
+ x = max(0, (w - len(header)) // 2)
431
+ addstr_safe(stdscr, 3, x, header, curses.A_BOLD | curses.A_UNDERLINE)
432
+ for i, r in enumerate(runs):
433
+ line = (f"{r['when']:<17} {r['lang']:<11} {r['wpm']:>6.1f} "
434
+ f"{r['accuracy']:>6.1f}% {r['seconds']:>6.1f}s")
435
+ addstr_safe(stdscr, 4 + i, x, line)
436
+
437
+ bests = {}
438
+ for r in stats["runs"]:
439
+ bests[r["lang"]] = max(bests.get(r["lang"], 0.0), r["wpm"])
440
+ best_line = "BESTS " + " ".join(
441
+ f"{lang} {v:.1f}" for lang, v in sorted(bests.items()))
442
+ addstr_safe(stdscr, 5 + len(runs), max(0, (w - len(best_line)) // 2),
443
+ best_line, curses.color_pair(C_CORRECT) | curses.A_BOLD)
444
+
445
+ hint = "any key to go back"
446
+ addstr_safe(stdscr, h - 1, max(0, (w - len(hint)) // 2), hint, curses.A_DIM)
447
+ stdscr.refresh()
448
+ stdscr.getch()
449
+
450
+
451
+ def pick_snippet(lang, avoid=None):
452
+ if lang == "sentences":
453
+ return random_sentence_test()
454
+ pool = SNIPPETS[lang]
455
+ choices = [s for s in pool if s != avoid] or pool
456
+ return random.choice(choices)
457
+
458
+
459
+ def main(stdscr):
460
+ curses.curs_set(0)
461
+ init_colors()
462
+ stdscr.keypad(True)
463
+
464
+ langs = sorted(SNIPPETS.keys())
465
+ while True:
466
+ options = langs + ["sentences (speed test)", "random", "history", "quit"]
467
+ choice = Menu(stdscr, "CODETYPE — offline code typing practice",
468
+ options, "↑↓ move Enter select Esc quit",
469
+ banner=banner_lines(),
470
+ tagline="⌨ offline code typing practice").run()
471
+ if choice is None or options[choice] == "quit":
472
+ return
473
+ if options[choice] == "history":
474
+ show_history(stdscr)
475
+ continue
476
+ lang = options[choice].split(" ")[0] # "sentences (speed test)" -> "sentences"
477
+
478
+ actual = random.choice(langs) if lang == "random" else lang
479
+ snippet = pick_snippet(actual)
480
+ while True:
481
+ game = TypingGame(stdscr, actual, snippet)
482
+ result = game.run()
483
+
484
+ if result == TypingGame.RESULT_MENU:
485
+ break
486
+ if result == TypingGame.RESULT_RESTART:
487
+ continue # same snippet, fresh state
488
+
489
+ action = show_results(stdscr, game)
490
+ if action == "menu":
491
+ break
492
+ if action == "next":
493
+ actual = random.choice(langs) if lang == "random" else lang
494
+ snippet = pick_snippet(actual, avoid=snippet)
495
+ # "retry" keeps the same snippet
496
+
497
+
498
+ def run():
499
+ curses.wrapper(main)
500
+
501
+
502
+ if __name__ == "__main__":
503
+ run()
@@ -0,0 +1,158 @@
1
+ """Built-in code snippets for CodeType, grouped by language.
2
+
3
+ Add your own: append strings to any list, or add a new language key.
4
+ Use spaces for indentation (no tabs).
5
+ """
6
+
7
+ import random
8
+
9
+ # Word pool for the plain-text speed test ("sentences" mode).
10
+ COMMON_WORDS = (
11
+ "the be to of and a in that have it for not on with he as you do at "
12
+ "this but his by from they we say her she or an will my one all would "
13
+ "there their what so up out if about who get which go me when make can "
14
+ "like time no just him know take people into year your good some could "
15
+ "them see other than then now look only come its over think also back "
16
+ "after use two how our work first well way even new want because any "
17
+ "these give day most us great where through much before right too means "
18
+ "old same tell does set three state never should here each while last "
19
+ "might still own under water long find down side been now part turn "
20
+ "place made live point world very still try hand high every near add "
21
+ "food between keep start thought help talk small end put home read "
22
+ "letter land change kind off need house picture again animal mother "
23
+ "world study learn plant cover story young life light open seem "
24
+ "together next white children begin got walk example paper often "
25
+ "always music both mark book until mile river car feet care second "
26
+ "group carry took rain eat room friend began idea fish mountain stop "
27
+ "once base hear horse cut sure watch color face wood main"
28
+ ).split()
29
+
30
+
31
+ def random_sentence_test(word_count=30, width=52):
32
+ """Generate a wrapped block of random common words for a speed test."""
33
+ words = random.choices(COMMON_WORDS, k=word_count)
34
+ lines, cur = [], ""
35
+ for w in words:
36
+ if cur and len(cur) + 1 + len(w) > width:
37
+ lines.append(cur)
38
+ cur = w
39
+ else:
40
+ cur = f"{cur} {w}" if cur else w
41
+ lines.append(cur)
42
+ return "\n".join(lines)
43
+
44
+ SNIPPETS = {
45
+ "python": [
46
+ 'def greet(name):\n return f"Hello, {name}!"',
47
+
48
+ 'squares = [x * x for x in range(10)]\nprint(sum(squares))',
49
+
50
+ 'def fib(n):\n a, b = 0, 1\n for _ in range(n):\n a, b = b, a + b\n return a',
51
+
52
+ 'with open("data.txt") as f:\n lines = [line.strip() for line in f]\n print(len(lines))',
53
+
54
+ 'class Stack:\n def __init__(self):\n self.items = []\n\n def push(self, item):\n self.items.append(item)\n\n def pop(self):\n return self.items.pop()',
55
+
56
+ 'import json\n\ndef load_config(path):\n try:\n with open(path) as f:\n return json.load(f)\n except FileNotFoundError:\n return {}',
57
+
58
+ 'def binary_search(arr, target):\n lo, hi = 0, len(arr) - 1\n while lo <= hi:\n mid = (lo + hi) // 2\n if arr[mid] == target:\n return mid\n if arr[mid] < target:\n lo = mid + 1\n else:\n hi = mid - 1\n return -1',
59
+ ],
60
+
61
+ "javascript": [
62
+ 'const double = (x) => x * 2;\nconsole.log([1, 2, 3].map(double));',
63
+
64
+ 'function debounce(fn, ms) {\n let timer;\n return (...args) => {\n clearTimeout(timer);\n timer = setTimeout(() => fn(...args), ms);\n };\n}',
65
+
66
+ 'const users = await fetch("/api/users")\n .then((res) => res.json())\n .catch(() => []);',
67
+
68
+ 'const unique = (arr) => [...new Set(arr)];\nconst sorted = unique([3, 1, 2, 3]).sort((a, b) => a - b);',
69
+
70
+ 'class Counter {\n #count = 0;\n\n increment() {\n return ++this.#count;\n }\n\n get value() {\n return this.#count;\n }\n}',
71
+
72
+ 'const groupBy = (arr, key) =>\n arr.reduce((acc, item) => {\n (acc[item[key]] ??= []).push(item);\n return acc;\n }, {});',
73
+ ],
74
+
75
+ "c": [
76
+ '#include <stdio.h>\n\nint main(void) {\n printf("Hello, world!\\n");\n return 0;\n}',
77
+
78
+ 'int max(int a, int b) {\n return a > b ? a : b;\n}',
79
+
80
+ 'void swap(int *a, int *b) {\n int tmp = *a;\n *a = *b;\n *b = tmp;\n}',
81
+
82
+ 'size_t my_strlen(const char *s) {\n const char *p = s;\n while (*p)\n p++;\n return (size_t)(p - s);\n}',
83
+
84
+ 'int factorial(int n) {\n int result = 1;\n for (int i = 2; i <= n; i++) {\n result *= i;\n }\n return result;\n}',
85
+ ],
86
+
87
+ "rust": [
88
+ 'fn add(a: i32, b: i32) -> i32 {\n a + b\n}',
89
+
90
+ 'fn main() {\n let nums = vec![1, 2, 3, 4];\n let sum: i32 = nums.iter().sum();\n println!("{}", sum);\n}',
91
+
92
+ 'fn largest(list: &[i32]) -> i32 {\n let mut largest = list[0];\n for &item in list {\n if item > largest {\n largest = item;\n }\n }\n largest\n}',
93
+
94
+ 'struct Point {\n x: f64,\n y: f64,\n}\n\nimpl Point {\n fn dist(&self) -> f64 {\n (self.x * self.x + self.y * self.y).sqrt()\n }\n}',
95
+
96
+ 'let result = match value {\n Some(n) if n > 0 => n * 2,\n Some(_) => 0,\n None => -1,\n};',
97
+ ],
98
+
99
+ "typescript": [
100
+ 'interface User {\n id: number;\n name: string;\n email?: string;\n}',
101
+
102
+ 'function identity<T>(value: T): T {\n return value;\n}',
103
+
104
+ 'type Result<T> =\n | { ok: true; value: T }\n | { ok: false; error: string };',
105
+
106
+ 'const pick = <T, K extends keyof T>(obj: T, keys: K[]): Pick<T, K> =>\n Object.fromEntries(keys.map((k) => [k, obj[k]])) as Pick<T, K>;',
107
+
108
+ 'async function getUser(id: number): Promise<User | null> {\n const res = await fetch(`/api/users/${id}`);\n if (!res.ok) return null;\n return res.json();\n}',
109
+ ],
110
+
111
+ "java": [
112
+ 'public class Main {\n public static void main(String[] args) {\n System.out.println("Hello, world!");\n }\n}',
113
+
114
+ 'public int max(int a, int b) {\n return a > b ? a : b;\n}',
115
+
116
+ 'List<String> names = users.stream()\n .filter(u -> u.isActive())\n .map(User::getName)\n .collect(Collectors.toList());',
117
+
118
+ 'public record Point(double x, double y) {\n public double dist() {\n return Math.sqrt(x * x + y * y);\n }\n}',
119
+
120
+ 'Map<String, Integer> counts = new HashMap<>();\nfor (String word : words) {\n counts.merge(word, 1, Integer::sum);\n}',
121
+ ],
122
+
123
+ "sql": [
124
+ 'SELECT name, email\nFROM users\nWHERE active = true\nORDER BY created_at DESC\nLIMIT 10;',
125
+
126
+ 'SELECT department, COUNT(*) AS total\nFROM employees\nGROUP BY department\nHAVING COUNT(*) > 5;',
127
+
128
+ 'UPDATE orders\nSET status = \'shipped\'\nWHERE id = 42;',
129
+
130
+ 'SELECT u.name, o.total\nFROM users u\nJOIN orders o ON o.user_id = u.id\nWHERE o.total > 100;',
131
+
132
+ 'CREATE TABLE posts (\n id SERIAL PRIMARY KEY,\n title TEXT NOT NULL,\n created_at TIMESTAMP DEFAULT NOW()\n);',
133
+ ],
134
+
135
+ "bash": [
136
+ 'for f in *.log; do\n gzip "$f"\ndone',
137
+
138
+ 'if [ -z "$1" ]; then\n echo "usage: $0 <name>" >&2\n exit 1\nfi',
139
+
140
+ 'count=$(grep -c "ERROR" app.log)\necho "found $count errors"',
141
+
142
+ 'while read -r line; do\n echo "line: $line"\ndone < input.txt',
143
+
144
+ 'backup() {\n local src="$1"\n tar -czf "${src}.tar.gz" "$src"\n}',
145
+ ],
146
+
147
+ "go": [
148
+ 'func add(a, b int) int {\n return a + b\n}',
149
+
150
+ 'package main\n\nimport "fmt"\n\nfunc main() {\n fmt.Println("Hello, world!")\n}',
151
+
152
+ 'func sum(nums []int) int {\n total := 0\n for _, n := range nums {\n total += n\n }\n return total\n}',
153
+
154
+ 'func contains(items []string, target string) bool {\n for _, item := range items {\n if item == target {\n return true\n }\n }\n return false\n}',
155
+
156
+ 'type Point struct {\n X, Y float64\n}\n\nfunc (p Point) Dist() float64 {\n return math.Sqrt(p.X*p.X + p.Y*p.Y)\n}',
157
+ ],
158
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "codetype",
3
+ "version": "1.0.0",
4
+ "description": "Offline terminal code-typing practice game (Python-powered)",
5
+ "license": "MIT",
6
+ "bin": {
7
+ "codetype": "bin/codetype.js"
8
+ },
9
+ "files": [
10
+ "bin/",
11
+ "codetype/**/*.py",
12
+ "README.md"
13
+ ],
14
+ "engines": {
15
+ "node": ">=14"
16
+ },
17
+ "os": [
18
+ "darwin",
19
+ "linux",
20
+ "win32"
21
+ ],
22
+ "keywords": [
23
+ "typing",
24
+ "typing-practice",
25
+ "cli",
26
+ "terminal",
27
+ "game",
28
+ "wpm",
29
+ "code"
30
+ ]
31
+ }