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 +55 -0
- package/bin/codetype.js +50 -0
- package/codetype/__init__.py +0 -0
- package/codetype/__main__.py +3 -0
- package/codetype/game.py +503 -0
- package/codetype/snippets.py +158 -0
- package/package.json +31 -0
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.
|
package/bin/codetype.js
ADDED
|
@@ -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
|
package/codetype/game.py
ADDED
|
@@ -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
|
+
}
|