oboeru 0.2.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.
- oboeru/__init__.py +3 -0
- oboeru/__main__.py +3 -0
- oboeru/app.py +667 -0
- oboeru/kanjidic2.sqlite +0 -0
- oboeru-0.2.0.dist-info/METADATA +58 -0
- oboeru-0.2.0.dist-info/RECORD +8 -0
- oboeru-0.2.0.dist-info/WHEEL +4 -0
- oboeru-0.2.0.dist-info/entry_points.txt +2 -0
oboeru/__init__.py
ADDED
oboeru/__main__.py
ADDED
oboeru/app.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"""oboeru (覚える) — terminal kanji quiz on a k2sqlite database.
|
|
2
|
+
|
|
3
|
+
Uses the database "app contract": questions come from the kanji_seed view
|
|
4
|
+
(one row per kanji, by JLPT level) and distractors from distractor_pool.
|
|
5
|
+
|
|
6
|
+
Progress (per-kanji correct/wrong + session history) is stored in a separate
|
|
7
|
+
SQLite file, so the app remembers you between runs: unseen and previously
|
|
8
|
+
missed kanji come back more often (Leitner boxes).
|
|
9
|
+
|
|
10
|
+
Run: oboeru [--db /path/to/kanjidic2.sqlite] (defaults to the bundled DB)
|
|
11
|
+
Keys: 1-4 or ↑/↓ + enter answer, enter next, q back/quit.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import random
|
|
18
|
+
import sqlite3
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from functools import lru_cache
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import ClassVar
|
|
24
|
+
|
|
25
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
26
|
+
from rich.text import Text
|
|
27
|
+
from textual.app import App, ComposeResult
|
|
28
|
+
from textual.binding import Binding
|
|
29
|
+
from textual.containers import Horizontal, Vertical
|
|
30
|
+
from textual.screen import Screen
|
|
31
|
+
from textual.widgets import Button, ProgressBar, Static
|
|
32
|
+
|
|
33
|
+
QUESTIONS_PER_ROUND = 10
|
|
34
|
+
MAX_BOX = 4 # Leitner box cap; box >= 3 counts as "mastered"
|
|
35
|
+
LEVEL_NAMES = {5: "N5", 4: "N4", 3: "N3", 2: "N2", 1: "N1"}
|
|
36
|
+
|
|
37
|
+
# First match wins: macOS, Linux (Noto), Windows
|
|
38
|
+
CJK_FONT_PATHS = [
|
|
39
|
+
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
|
40
|
+
"/System/Library/Fonts/AppleSDGothicNeo.ttc",
|
|
41
|
+
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
|
|
42
|
+
"/usr/share/fonts/noto-cjk/NotoSansCJK-Regular.ttc",
|
|
43
|
+
"C:/Windows/Fonts/msgothic.ttc",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@lru_cache(maxsize=4)
|
|
48
|
+
def _cjk_font(size: int) -> ImageFont.FreeTypeFont | None:
|
|
49
|
+
"""First available system CJK font at the given pixel size, or None."""
|
|
50
|
+
for path in CJK_FONT_PATHS:
|
|
51
|
+
if Path(path).exists():
|
|
52
|
+
return ImageFont.truetype(path, size)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def kanji_art(literal: str, pixels: int = 24, color: str = "#e0af68"):
|
|
57
|
+
"""Render a kanji glyph as big half-block art (▀ ▄ █), two image rows
|
|
58
|
+
per terminal row. Falls back to the plain literal when no CJK font is
|
|
59
|
+
available."""
|
|
60
|
+
font = _cjk_font(pixels * 2)
|
|
61
|
+
if font is None:
|
|
62
|
+
return literal
|
|
63
|
+
# Draw large, crop to the glyph, scale into a square canvas
|
|
64
|
+
img = Image.new("L", (pixels * 4, pixels * 4), 0)
|
|
65
|
+
ImageDraw.Draw(img).text(
|
|
66
|
+
(pixels * 2, pixels * 2), literal, font=font, fill=255, anchor="mm"
|
|
67
|
+
)
|
|
68
|
+
bbox = img.getbbox()
|
|
69
|
+
if bbox is None:
|
|
70
|
+
return literal
|
|
71
|
+
img = img.crop(bbox)
|
|
72
|
+
img.thumbnail((pixels, pixels), Image.LANCZOS)
|
|
73
|
+
canvas = Image.new("L", (pixels, pixels), 0)
|
|
74
|
+
canvas.paste(img, ((pixels - img.width) // 2, (pixels - img.height) // 2))
|
|
75
|
+
# Two vertical pixels per cell: top+bottom=█, top=▀, bottom=▄
|
|
76
|
+
art = Text()
|
|
77
|
+
px = canvas.load()
|
|
78
|
+
for y in range(0, pixels - 1, 2):
|
|
79
|
+
for x in range(pixels):
|
|
80
|
+
top, bottom = px[x, y] > 127, px[x, y + 1] > 127
|
|
81
|
+
if top:
|
|
82
|
+
art.append("█" if bottom else "▀", style=color)
|
|
83
|
+
elif bottom:
|
|
84
|
+
art.append("▄", style=color)
|
|
85
|
+
else:
|
|
86
|
+
art.append(" ")
|
|
87
|
+
if y + 2 < pixels:
|
|
88
|
+
art.append("\n")
|
|
89
|
+
return art
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def nudge(widget, frames: list[tuple[int, int]], step: float = 0.06) -> None:
|
|
93
|
+
"""Frame-based offset animation (slide/shake/bounce).
|
|
94
|
+
|
|
95
|
+
textual's styles.animate() can't blend the offset property, so we step
|
|
96
|
+
through (x, y) frames on a timer instead — simple and version-proof.
|
|
97
|
+
"""
|
|
98
|
+
frames_iter = iter(frames)
|
|
99
|
+
state = {"timer": None}
|
|
100
|
+
|
|
101
|
+
def tick() -> None:
|
|
102
|
+
if not widget.is_mounted:
|
|
103
|
+
state["timer"].stop()
|
|
104
|
+
return
|
|
105
|
+
try:
|
|
106
|
+
widget.styles.offset = next(frames_iter)
|
|
107
|
+
except StopIteration:
|
|
108
|
+
state["timer"].stop()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
widget.styles.offset = next(frames_iter)
|
|
112
|
+
except StopIteration:
|
|
113
|
+
return
|
|
114
|
+
state["timer"] = widget.set_interval(step, tick)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass
|
|
118
|
+
class Question:
|
|
119
|
+
literal: str
|
|
120
|
+
meaning: str
|
|
121
|
+
on_readings: str
|
|
122
|
+
kun_readings: str
|
|
123
|
+
choices: list[str]
|
|
124
|
+
correct_index: int
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class KanjiDB:
|
|
128
|
+
"""Read-only access to the k2sqlite database via its app contract views."""
|
|
129
|
+
|
|
130
|
+
def __init__(self, path: Path):
|
|
131
|
+
self.conn = sqlite3.connect(path)
|
|
132
|
+
|
|
133
|
+
def levels(self) -> list[tuple[int, int]]:
|
|
134
|
+
"""Available JLPT levels with kanji counts, easiest first."""
|
|
135
|
+
return self.conn.execute(
|
|
136
|
+
"SELECT lvl, COUNT(*) FROM kanji_seed GROUP BY lvl ORDER BY lvl DESC"
|
|
137
|
+
).fetchall()
|
|
138
|
+
|
|
139
|
+
def round_questions(self, lvl: int, n: int, progress: dict) -> list[Question]:
|
|
140
|
+
"""Pick n kanji without replacement, weighted by learning need."""
|
|
141
|
+
rows = self.conn.execute(
|
|
142
|
+
"SELECT literal, main_meaning, on_prime, kun_prime"
|
|
143
|
+
" FROM kanji_seed WHERE lvl=?",
|
|
144
|
+
(lvl,),
|
|
145
|
+
).fetchall()
|
|
146
|
+
pool = [(self._weight(progress.get(row[0])), row) for row in rows]
|
|
147
|
+
picked = []
|
|
148
|
+
for _ in range(min(n, len(pool))):
|
|
149
|
+
hit = random.uniform(0, sum(w for w, _ in pool))
|
|
150
|
+
acc = 0.0
|
|
151
|
+
for i, (w, row) in enumerate(pool):
|
|
152
|
+
acc += w
|
|
153
|
+
if acc >= hit:
|
|
154
|
+
picked.append(row)
|
|
155
|
+
pool.pop(i)
|
|
156
|
+
break
|
|
157
|
+
return [self._make_question(lvl, row) for row in picked]
|
|
158
|
+
|
|
159
|
+
@staticmethod
|
|
160
|
+
def _weight(p: tuple[int, int, int] | None) -> float:
|
|
161
|
+
"""Selection weight: unseen > often-wrong > mastered."""
|
|
162
|
+
if p is None:
|
|
163
|
+
return 6.0
|
|
164
|
+
_correct, wrong, box = p
|
|
165
|
+
return (1.0 + 2.0 * wrong) / (1.0 + box) + 0.1
|
|
166
|
+
|
|
167
|
+
def _make_question(self, lvl: int, row) -> Question:
|
|
168
|
+
literal, meaning, on_prime, kun_prime = row
|
|
169
|
+
distractors = [
|
|
170
|
+
d
|
|
171
|
+
for (d,) in self.conn.execute(
|
|
172
|
+
"""SELECT DISTINCT meaning FROM distractor_pool
|
|
173
|
+
WHERE lvl=? AND meaning<>?
|
|
174
|
+
AND meaning NOT IN
|
|
175
|
+
(SELECT meaning FROM kanji_meaning WHERE literal=?)
|
|
176
|
+
ORDER BY RANDOM() LIMIT 3""",
|
|
177
|
+
(lvl, meaning, literal),
|
|
178
|
+
)
|
|
179
|
+
]
|
|
180
|
+
choices = [meaning] + distractors
|
|
181
|
+
random.shuffle(choices)
|
|
182
|
+
return Question(
|
|
183
|
+
literal, meaning, on_prime, kun_prime, choices, choices.index(meaning)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class ProgressDB:
|
|
188
|
+
"""Per-kanji progress + session history (separate from the kanji data)."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, path: Path):
|
|
191
|
+
self.conn = sqlite3.connect(path)
|
|
192
|
+
self.conn.executescript("""
|
|
193
|
+
CREATE TABLE IF NOT EXISTS progress(
|
|
194
|
+
literal TEXT PRIMARY KEY,
|
|
195
|
+
correct INTEGER NOT NULL DEFAULT 0,
|
|
196
|
+
wrong INTEGER NOT NULL DEFAULT 0,
|
|
197
|
+
box INTEGER NOT NULL DEFAULT 0,
|
|
198
|
+
last_seen TEXT NOT NULL
|
|
199
|
+
);
|
|
200
|
+
CREATE TABLE IF NOT EXISTS session(
|
|
201
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
202
|
+
played_at TEXT NOT NULL,
|
|
203
|
+
lvl INTEGER NOT NULL,
|
|
204
|
+
questions INTEGER NOT NULL,
|
|
205
|
+
correct INTEGER NOT NULL,
|
|
206
|
+
best_streak INTEGER NOT NULL
|
|
207
|
+
);
|
|
208
|
+
""")
|
|
209
|
+
|
|
210
|
+
def progress_map(self) -> dict[str, tuple[int, int, int]]:
|
|
211
|
+
"""literal -> (correct, wrong, box) for all seen kanji."""
|
|
212
|
+
return {
|
|
213
|
+
lit: (c, w, b)
|
|
214
|
+
for lit, c, w, b in self.conn.execute(
|
|
215
|
+
"SELECT literal, correct, wrong, box FROM progress"
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
def record(self, literal: str, ok: bool) -> None:
|
|
220
|
+
row = self.conn.execute(
|
|
221
|
+
"SELECT correct, wrong, box FROM progress WHERE literal=?", (literal,)
|
|
222
|
+
).fetchone()
|
|
223
|
+
correct, wrong, box = row if row else (0, 0, 0)
|
|
224
|
+
if ok:
|
|
225
|
+
correct += 1
|
|
226
|
+
box = min(MAX_BOX, box + 1)
|
|
227
|
+
else:
|
|
228
|
+
wrong += 1
|
|
229
|
+
box = 0 # back to square one (Leitner)
|
|
230
|
+
self.conn.execute(
|
|
231
|
+
"INSERT OR REPLACE INTO progress"
|
|
232
|
+
"(literal, correct, wrong, box, last_seen) VALUES (?,?,?,?,?)",
|
|
233
|
+
(literal, correct, wrong, box, datetime.now(timezone.utc).isoformat()),
|
|
234
|
+
)
|
|
235
|
+
self.conn.commit()
|
|
236
|
+
|
|
237
|
+
def save_session(
|
|
238
|
+
self, lvl: int, questions: int, correct: int, best_streak: int
|
|
239
|
+
) -> None:
|
|
240
|
+
self.conn.execute(
|
|
241
|
+
"INSERT INTO session(played_at, lvl, questions, correct, best_streak)"
|
|
242
|
+
" VALUES (?,?,?,?,?)",
|
|
243
|
+
(
|
|
244
|
+
datetime.now(timezone.utc).isoformat(),
|
|
245
|
+
lvl,
|
|
246
|
+
questions,
|
|
247
|
+
correct,
|
|
248
|
+
best_streak,
|
|
249
|
+
),
|
|
250
|
+
)
|
|
251
|
+
self.conn.commit()
|
|
252
|
+
|
|
253
|
+
def lifetime(self) -> tuple[int, int, int, int]:
|
|
254
|
+
"""(total answers, total correct, mastered kanji, sessions played)."""
|
|
255
|
+
answers, correct = self.conn.execute(
|
|
256
|
+
"SELECT COALESCE(SUM(correct) + SUM(wrong), 0),"
|
|
257
|
+
" COALESCE(SUM(correct), 0) FROM progress"
|
|
258
|
+
).fetchone()
|
|
259
|
+
mastered = self.conn.execute(
|
|
260
|
+
"SELECT COUNT(*) FROM progress WHERE box >= 3"
|
|
261
|
+
).fetchone()[0]
|
|
262
|
+
sessions = self.conn.execute("SELECT COUNT(*) FROM session").fetchone()[0]
|
|
263
|
+
return answers, correct, mastered, sessions
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
class StartScreen(Screen):
|
|
267
|
+
BINDINGS: ClassVar = [Binding("q", "quit_app", "Quit")]
|
|
268
|
+
|
|
269
|
+
TITLE = "K A N J I Q U I Z"
|
|
270
|
+
|
|
271
|
+
def compose(self) -> ComposeResult:
|
|
272
|
+
answers, correct, mastered, sessions = self.app.progress.lifetime()
|
|
273
|
+
acc = f"{100 * correct / answers:.0f}%" if answers else "—"
|
|
274
|
+
yield Vertical(
|
|
275
|
+
Static("漢 字", id="logo"),
|
|
276
|
+
Static("", id="title"),
|
|
277
|
+
Static(
|
|
278
|
+
f"answered {answers} ・ accuracy {acc}"
|
|
279
|
+
f" ・ mastered {mastered} ・ sessions {sessions}",
|
|
280
|
+
id="life",
|
|
281
|
+
),
|
|
282
|
+
Horizontal(
|
|
283
|
+
*[
|
|
284
|
+
Button(f"{LEVEL_NAMES[lvl]}\n{count} kanji", id=f"lvl-{lvl}")
|
|
285
|
+
for lvl, count in self.app.kanji.levels()
|
|
286
|
+
],
|
|
287
|
+
id="levels",
|
|
288
|
+
),
|
|
289
|
+
Static("pick a level — press q to quit", id="hint"),
|
|
290
|
+
id="start",
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
def on_mount(self) -> None:
|
|
294
|
+
# Typewriter title + gentle entrances
|
|
295
|
+
self._title_len = 0
|
|
296
|
+
self._title_timer = self.set_interval(0.06, self._type_title)
|
|
297
|
+
logo = self.query_one("#logo", Static)
|
|
298
|
+
logo.styles.opacity = 0.0
|
|
299
|
+
logo.styles.animate("opacity", 1.0, duration=0.8)
|
|
300
|
+
for i, btn in enumerate(self.query("#levels Button")):
|
|
301
|
+
btn.styles.opacity = 0.0
|
|
302
|
+
btn.styles.animate("opacity", 1.0, duration=0.3, delay=0.5 + 0.08 * i)
|
|
303
|
+
|
|
304
|
+
def _type_title(self) -> None:
|
|
305
|
+
if not self.is_mounted:
|
|
306
|
+
self._title_timer.stop()
|
|
307
|
+
return
|
|
308
|
+
self._title_len += 1
|
|
309
|
+
self.query_one("#title", Static).update(self.TITLE[: self._title_len])
|
|
310
|
+
if self._title_len >= len(self.TITLE):
|
|
311
|
+
self._title_timer.stop()
|
|
312
|
+
|
|
313
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
314
|
+
if event.button.id and event.button.id.startswith("lvl-"):
|
|
315
|
+
self.app.switch_screen(QuizScreen(int(event.button.id.split("-")[1])))
|
|
316
|
+
|
|
317
|
+
def action_quit_app(self) -> None:
|
|
318
|
+
self.app.exit()
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class QuizScreen(Screen):
|
|
322
|
+
BINDINGS: ClassVar = [
|
|
323
|
+
Binding("1", "answer(0)", show=False),
|
|
324
|
+
Binding("2", "answer(1)", show=False),
|
|
325
|
+
Binding("3", "answer(2)", show=False),
|
|
326
|
+
Binding("4", "answer(3)", show=False),
|
|
327
|
+
Binding("up", "app.focus_previous", show=False),
|
|
328
|
+
Binding("down", "app.focus_next", show=False),
|
|
329
|
+
Binding("enter", "next", "Next"),
|
|
330
|
+
Binding("space", "next", show=False),
|
|
331
|
+
Binding("q", "to_levels", "Levels"),
|
|
332
|
+
]
|
|
333
|
+
|
|
334
|
+
def __init__(self, lvl: int):
|
|
335
|
+
super().__init__()
|
|
336
|
+
self.lvl = lvl
|
|
337
|
+
self.questions: list[Question] = []
|
|
338
|
+
self.index = 0
|
|
339
|
+
self.score = 0
|
|
340
|
+
self.streak = 0
|
|
341
|
+
self.best_streak = 0
|
|
342
|
+
self.correct_count = 0
|
|
343
|
+
self.missed: list[Question] = []
|
|
344
|
+
self.answered = False
|
|
345
|
+
|
|
346
|
+
def compose(self) -> ComposeResult:
|
|
347
|
+
yield Vertical(
|
|
348
|
+
Static(id="stats"),
|
|
349
|
+
ProgressBar(total=QUESTIONS_PER_ROUND, show_eta=False, id="bar"),
|
|
350
|
+
Horizontal(
|
|
351
|
+
Vertical(Static(id="kanji"), id="kanji-box"),
|
|
352
|
+
Vertical(
|
|
353
|
+
*[Button(id=f"ans-{i}", classes="answer") for i in range(4)],
|
|
354
|
+
Static(id="feedback", classes="hidden"),
|
|
355
|
+
Static(id="readings", classes="hidden"),
|
|
356
|
+
id="side",
|
|
357
|
+
),
|
|
358
|
+
id="main",
|
|
359
|
+
),
|
|
360
|
+
Static("1-4 / ↑↓ answer ・ enter next ・ q levels", id="quiz-hint"),
|
|
361
|
+
id="quiz",
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
def on_mount(self) -> None:
|
|
365
|
+
self.questions = self.app.kanji.round_questions(
|
|
366
|
+
self.lvl, QUESTIONS_PER_ROUND, self.app.progress.progress_map()
|
|
367
|
+
)
|
|
368
|
+
self.show_question()
|
|
369
|
+
|
|
370
|
+
def show_question(self) -> None:
|
|
371
|
+
q = self.questions[self.index]
|
|
372
|
+
self.answered = False
|
|
373
|
+
kanji = self.query_one("#kanji", Static)
|
|
374
|
+
kanji.update(kanji_art(q.literal, self.app.kanji_size))
|
|
375
|
+
# Fade + slide the kanji in
|
|
376
|
+
kanji.styles.opacity = 0.0
|
|
377
|
+
kanji.styles.animate("opacity", 1.0, duration=0.25)
|
|
378
|
+
nudge(kanji, [(0, -2), (0, -1), (0, 0)], step=0.05)
|
|
379
|
+
for widget_id in ("#feedback", "#readings"):
|
|
380
|
+
self.query_one(widget_id, Static).add_class("hidden")
|
|
381
|
+
for i, btn in enumerate(self.query(".answer")):
|
|
382
|
+
btn.label = q.choices[i] if i < len(q.choices) else "—"
|
|
383
|
+
btn.disabled = i >= len(q.choices)
|
|
384
|
+
btn.remove_class("correct", "wrong")
|
|
385
|
+
# Staggered entrance
|
|
386
|
+
btn.styles.opacity = 0.0
|
|
387
|
+
btn.styles.animate("opacity", 1.0, duration=0.2, delay=0.08 + 0.05 * i)
|
|
388
|
+
# Pre-focus the first option for keyboard play (↑/↓ + enter)
|
|
389
|
+
self.query_one("#ans-0", Button).focus()
|
|
390
|
+
self.query_one("#bar", ProgressBar).update(progress=self.index)
|
|
391
|
+
self.update_stats()
|
|
392
|
+
|
|
393
|
+
def update_stats(self) -> None:
|
|
394
|
+
flame = f" 🔥 streak {self.streak}" if self.streak >= 2 else ""
|
|
395
|
+
self.query_one("#stats", Static).update(
|
|
396
|
+
f"{LEVEL_NAMES[self.lvl]}"
|
|
397
|
+
f" {self.index + 1}/{len(self.questions)}"
|
|
398
|
+
f" score {self.score}{flame}"
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def action_answer(self, i: int) -> None:
|
|
402
|
+
if self.answered:
|
|
403
|
+
return
|
|
404
|
+
q = self.questions[self.index]
|
|
405
|
+
if i >= len(q.choices):
|
|
406
|
+
return
|
|
407
|
+
self.answered = True
|
|
408
|
+
ok = i == q.correct_index
|
|
409
|
+
buttons = list(self.query(".answer"))
|
|
410
|
+
for j, btn in enumerate(buttons):
|
|
411
|
+
btn.disabled = True
|
|
412
|
+
if j == q.correct_index:
|
|
413
|
+
btn.add_class("correct")
|
|
414
|
+
# Nothing left to select: drop focus so enter means "next"
|
|
415
|
+
self.app.set_focus(None)
|
|
416
|
+
feedback = self.query_one("#feedback", Static)
|
|
417
|
+
kanji = self.query_one("#kanji", Static)
|
|
418
|
+
if ok:
|
|
419
|
+
self.streak += 1
|
|
420
|
+
self.best_streak = max(self.best_streak, self.streak)
|
|
421
|
+
gained = 10 + 5 * (self.streak - 1)
|
|
422
|
+
self.score += gained
|
|
423
|
+
self.correct_count += 1
|
|
424
|
+
feedback.update(f"✓ Correct! +{gained}")
|
|
425
|
+
feedback.remove_class("bad")
|
|
426
|
+
feedback.add_class("ok")
|
|
427
|
+
if self.streak >= 2:
|
|
428
|
+
# Bounce the stats bar to celebrate the streak
|
|
429
|
+
nudge(
|
|
430
|
+
self.query_one("#stats", Static),
|
|
431
|
+
[(0, -1), (0, 0), (0, -1), (0, 0)],
|
|
432
|
+
step=0.07,
|
|
433
|
+
)
|
|
434
|
+
else:
|
|
435
|
+
buttons[i].add_class("wrong")
|
|
436
|
+
self.streak = 0
|
|
437
|
+
self.missed.append(q)
|
|
438
|
+
feedback.update(f"✗ {q.literal} means “{q.meaning}”")
|
|
439
|
+
feedback.remove_class("ok")
|
|
440
|
+
feedback.add_class("bad")
|
|
441
|
+
# Shake the kanji panel
|
|
442
|
+
nudge(kanji, [(2, 0), (-2, 0), (1, 0), (-1, 0), (0, 0)], step=0.05)
|
|
443
|
+
# Fade the verdict in
|
|
444
|
+
feedback.styles.opacity = 0.0
|
|
445
|
+
feedback.styles.animate("opacity", 1.0, duration=0.2)
|
|
446
|
+
self.app.progress.record(q.literal, ok)
|
|
447
|
+
self.query_one("#readings", Static).update(
|
|
448
|
+
f"on: {q.on_readings or '—'} kun: {q.kun_readings or '—'}"
|
|
449
|
+
" — enter for next →"
|
|
450
|
+
)
|
|
451
|
+
feedback.remove_class("hidden")
|
|
452
|
+
self.query_one("#readings", Static).remove_class("hidden")
|
|
453
|
+
self.update_stats()
|
|
454
|
+
|
|
455
|
+
def action_next(self) -> None:
|
|
456
|
+
if not self.answered:
|
|
457
|
+
return
|
|
458
|
+
self.index += 1
|
|
459
|
+
if self.index >= len(self.questions):
|
|
460
|
+
self.app.progress.save_session(
|
|
461
|
+
self.lvl, len(self.questions), self.correct_count, self.best_streak
|
|
462
|
+
)
|
|
463
|
+
self.app.switch_screen(
|
|
464
|
+
SummaryScreen(
|
|
465
|
+
self.lvl,
|
|
466
|
+
self.correct_count,
|
|
467
|
+
len(self.questions),
|
|
468
|
+
self.best_streak,
|
|
469
|
+
self.score,
|
|
470
|
+
self.missed,
|
|
471
|
+
)
|
|
472
|
+
)
|
|
473
|
+
else:
|
|
474
|
+
self.show_question()
|
|
475
|
+
|
|
476
|
+
def action_to_levels(self) -> None:
|
|
477
|
+
self.app.switch_screen(StartScreen())
|
|
478
|
+
|
|
479
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
480
|
+
if event.button.id and event.button.id.startswith("ans-"):
|
|
481
|
+
self.action_answer(int(event.button.id.split("-")[1]))
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class SummaryScreen(Screen):
|
|
485
|
+
BINDINGS: ClassVar = [
|
|
486
|
+
Binding("enter", "again", "Again"),
|
|
487
|
+
Binding("q", "levels", "Levels"),
|
|
488
|
+
]
|
|
489
|
+
|
|
490
|
+
def __init__(
|
|
491
|
+
self,
|
|
492
|
+
lvl: int,
|
|
493
|
+
correct: int,
|
|
494
|
+
total: int,
|
|
495
|
+
best_streak: int,
|
|
496
|
+
score: int,
|
|
497
|
+
missed: list[Question],
|
|
498
|
+
):
|
|
499
|
+
super().__init__()
|
|
500
|
+
self.lvl = lvl
|
|
501
|
+
self.correct = correct
|
|
502
|
+
self.total = total
|
|
503
|
+
self.best_streak = best_streak
|
|
504
|
+
self.score = score
|
|
505
|
+
self.missed = missed
|
|
506
|
+
|
|
507
|
+
def compose(self) -> ComposeResult:
|
|
508
|
+
rows = [
|
|
509
|
+
Static("R O U N D C O M P L E T E", id="sum-title"),
|
|
510
|
+
Static("", id="sum-stats"),
|
|
511
|
+
]
|
|
512
|
+
if self.missed:
|
|
513
|
+
lines = "\n".join(f" {q.literal} {q.meaning}" for q in self.missed)
|
|
514
|
+
rows.append(Static(f"review these:\n{lines}", id="sum-missed"))
|
|
515
|
+
rows.append(
|
|
516
|
+
Horizontal(
|
|
517
|
+
Button("Again (enter)", id="again"),
|
|
518
|
+
Button("Levels (q)", id="levels"),
|
|
519
|
+
Button("Quit", id="quit"),
|
|
520
|
+
id="sum-buttons",
|
|
521
|
+
)
|
|
522
|
+
)
|
|
523
|
+
yield Vertical(*rows, id="summary")
|
|
524
|
+
|
|
525
|
+
def on_mount(self) -> None:
|
|
526
|
+
# Score counts up from zero
|
|
527
|
+
self._shown_score = 0
|
|
528
|
+
self._render_stats()
|
|
529
|
+
if self.score:
|
|
530
|
+
self._score_timer = self.set_interval(0.04, self._tick_score)
|
|
531
|
+
|
|
532
|
+
def _render_stats(self) -> None:
|
|
533
|
+
acc = 100 * self.correct / self.total if self.total else 0
|
|
534
|
+
self.query_one("#sum-stats", Static).update(
|
|
535
|
+
f"{LEVEL_NAMES[self.lvl]} ・ score {self._shown_score} ・ "
|
|
536
|
+
f"✓ {self.correct}/{self.total} ({acc:.0f}%) ・ "
|
|
537
|
+
f"🔥 best streak {self.best_streak}"
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _tick_score(self) -> None:
|
|
541
|
+
if not self.is_mounted:
|
|
542
|
+
self._score_timer.stop()
|
|
543
|
+
return
|
|
544
|
+
self._shown_score = min(
|
|
545
|
+
self.score, self._shown_score + max(1, self.score // 25)
|
|
546
|
+
)
|
|
547
|
+
self._render_stats()
|
|
548
|
+
if self._shown_score >= self.score:
|
|
549
|
+
self._score_timer.stop()
|
|
550
|
+
|
|
551
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
552
|
+
if event.button.id == "again":
|
|
553
|
+
self.action_again()
|
|
554
|
+
elif event.button.id == "levels":
|
|
555
|
+
self.action_levels()
|
|
556
|
+
elif event.button.id == "quit":
|
|
557
|
+
self.app.exit()
|
|
558
|
+
|
|
559
|
+
def action_again(self) -> None:
|
|
560
|
+
self.app.switch_screen(QuizScreen(self.lvl))
|
|
561
|
+
|
|
562
|
+
def action_levels(self) -> None:
|
|
563
|
+
self.app.switch_screen(StartScreen())
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
class KanjiQuizApp(App):
|
|
567
|
+
CSS = """
|
|
568
|
+
Screen { background: #1a1b26; color: #c0caf5; }
|
|
569
|
+
#start, #quiz, #summary { align: center middle; }
|
|
570
|
+
|
|
571
|
+
#logo { text-align: center; color: #e0af68; text-style: bold; }
|
|
572
|
+
#title { text-align: center; color: #7aa2f7; text-style: bold; }
|
|
573
|
+
#life { text-align: center; color: #9ece6a; margin: 1 0; }
|
|
574
|
+
#levels { height: auto; align: center middle; }
|
|
575
|
+
#levels Button { width: 16; margin: 0 1; }
|
|
576
|
+
#hint { text-align: center; color: #565f89; margin-top: 2; }
|
|
577
|
+
|
|
578
|
+
#stats { text-align: center; color: #e0af68; text-style: bold; }
|
|
579
|
+
#bar { width: 60; margin: 0 0 1 0; }
|
|
580
|
+
#main { height: auto; align: center middle; }
|
|
581
|
+
#kanji-box { height: auto; margin: 0 1; }
|
|
582
|
+
#kanji {
|
|
583
|
+
border: heavy #7aa2f7;
|
|
584
|
+
background: #24283b;
|
|
585
|
+
padding: 1 3;
|
|
586
|
+
text-align: center;
|
|
587
|
+
text-style: bold;
|
|
588
|
+
}
|
|
589
|
+
#side { height: auto; margin: 0 1; }
|
|
590
|
+
.answer { width: 44; margin-bottom: 1; }
|
|
591
|
+
.answer:focus { background: #414868; text-style: bold; }
|
|
592
|
+
.answer.correct { background: #9ece6a; color: #1a1b26; text-style: bold; }
|
|
593
|
+
.answer.wrong { background: #f7768e; color: #1a1b26; text-style: bold; }
|
|
594
|
+
#quiz-hint { text-align: center; color: #565f89; margin-top: 1; }
|
|
595
|
+
#feedback { text-style: bold; margin-top: 1; }
|
|
596
|
+
#feedback.ok { color: #9ece6a; }
|
|
597
|
+
#feedback.bad { color: #f7768e; }
|
|
598
|
+
#readings { color: #bb9af7; }
|
|
599
|
+
.hidden { display: none; }
|
|
600
|
+
|
|
601
|
+
#sum-title { text-align: center; color: #7aa2f7; text-style: bold; }
|
|
602
|
+
#sum-stats { text-align: center; color: #e0af68; margin: 1 0; }
|
|
603
|
+
#sum-missed {
|
|
604
|
+
text-align: center;
|
|
605
|
+
color: #f7768e;
|
|
606
|
+
border: round #f7768e;
|
|
607
|
+
padding: 1 3;
|
|
608
|
+
margin: 1 0;
|
|
609
|
+
}
|
|
610
|
+
#sum-buttons { height: auto; align: center middle; margin-top: 1; }
|
|
611
|
+
#sum-buttons Button { margin: 0 1; }
|
|
612
|
+
"""
|
|
613
|
+
|
|
614
|
+
def __init__(self, db_path: Path, progress_path: Path, kanji_size: int = 24):
|
|
615
|
+
super().__init__()
|
|
616
|
+
self.kanji = KanjiDB(db_path)
|
|
617
|
+
self.progress = ProgressDB(progress_path)
|
|
618
|
+
self.kanji_size = kanji_size
|
|
619
|
+
|
|
620
|
+
def on_mount(self) -> None:
|
|
621
|
+
self.push_screen(StartScreen())
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def main() -> None:
|
|
625
|
+
parser = argparse.ArgumentParser(
|
|
626
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
627
|
+
)
|
|
628
|
+
parser.add_argument(
|
|
629
|
+
"--db",
|
|
630
|
+
type=Path,
|
|
631
|
+
default=None,
|
|
632
|
+
help="path to kanjidic2.sqlite (default: the bundled database)",
|
|
633
|
+
)
|
|
634
|
+
parser.add_argument(
|
|
635
|
+
"--progress",
|
|
636
|
+
type=Path,
|
|
637
|
+
default=None,
|
|
638
|
+
help="progress database (default: alongside --db)",
|
|
639
|
+
)
|
|
640
|
+
parser.add_argument(
|
|
641
|
+
"--kanji-size",
|
|
642
|
+
type=int,
|
|
643
|
+
default=24,
|
|
644
|
+
help="kanji art size in pixels (terminal rows = size/2; default: 24)",
|
|
645
|
+
)
|
|
646
|
+
args = parser.parse_args()
|
|
647
|
+
bundled = Path(__file__).with_name("kanjidic2.sqlite")
|
|
648
|
+
db = args.db or (bundled if bundled.exists() else Path("output/kanjidic2.sqlite"))
|
|
649
|
+
if not db.exists():
|
|
650
|
+
parser.error(
|
|
651
|
+
f"{db} not found — pass --db (get kanjidic2.sqlite from "
|
|
652
|
+
"github.com/trygu/k2sqlite/releases)"
|
|
653
|
+
)
|
|
654
|
+
if args.progress:
|
|
655
|
+
progress = args.progress
|
|
656
|
+
elif db == bundled:
|
|
657
|
+
# The bundled DB lives in site-packages (read-only), so keep progress
|
|
658
|
+
# in the user's home directory.
|
|
659
|
+
progress = Path.home() / ".oboeru" / "quiz_progress.sqlite"
|
|
660
|
+
progress.parent.mkdir(parents=True, exist_ok=True)
|
|
661
|
+
else:
|
|
662
|
+
progress = db.with_name("quiz_progress.sqlite")
|
|
663
|
+
KanjiQuizApp(db, progress, kanji_size=args.kanji_size).run()
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
if __name__ == "__main__":
|
|
667
|
+
main()
|
oboeru/kanjidic2.sqlite
ADDED
|
Binary file
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: oboeru
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Addictive terminal kanji quiz (TUI) on KANJIDIC2 data
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: pillow>=12
|
|
8
|
+
Requires-Dist: textual>=8.2.8
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# oboeru (覚える)
|
|
12
|
+
|
|
13
|
+
*Oboeru* is Japanese for "to memorize / learn by heart" — which is exactly what
|
|
14
|
+
this terminal kanji quiz helps you do. Fast, keyboard-driven, and just
|
|
15
|
+
addictive enough to keep you coming back.
|
|
16
|
+
|
|
17
|
+
Built with [textual](https://textual.textualize.io/) on
|
|
18
|
+
[KANJIDIC2](https://www.edrdg.org/wiki/index.php/KANJIDIC_Project) data.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
|
|
22
|
+
- Rounds of 10 multiple-choice "kanji → meaning" questions per JLPT level (N5–N1)
|
|
23
|
+
- Big half-block kanji art (needs a system CJK font; falls back to plain text)
|
|
24
|
+
- Adaptive selection: unseen and previously missed kanji come back more often
|
|
25
|
+
(Leitner boxes), mastered kanji fade away
|
|
26
|
+
- Sessions are saved: per-kanji progress + session history in a small SQLite
|
|
27
|
+
file; the menu shows lifetime accuracy and mastered count
|
|
28
|
+
- Score with streak bonuses 🔥, animations, and a review list after each round
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install oboeru
|
|
34
|
+
# or run directly without installing:
|
|
35
|
+
uvx oboeru
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The KANJIDIC2 database (built by
|
|
39
|
+
[k2sqlite](https://github.com/trygu/k2sqlite)) is bundled with the package,
|
|
40
|
+
so no setup is needed. Pass `--db /path/to/kanjidic2.sqlite` if you have your
|
|
41
|
+
own copy.
|
|
42
|
+
|
|
43
|
+
## Run
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
oboeru
|
|
47
|
+
oboeru --help # all options (--db, --kanji-size, --progress)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Keys: `1-4` or `↑/↓` + `enter` answer, `enter` next question, `q` back/quit.
|
|
51
|
+
|
|
52
|
+
Progress is stored in `~/.oboeru/quiz_progress.sqlite` (or next to a custom
|
|
53
|
+
`--db` database) unless `--progress` says otherwise — your streaks survive
|
|
54
|
+
restarts.
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
oboeru/__init__.py,sha256=TChWutkTVAAPY2kzZg3dSAXdmfEmFQV928oPnHDkQMU,101
|
|
2
|
+
oboeru/__main__.py,sha256=cYN1Iv28egkON6JJAdUPJuy-C-F6p8KvWk7pYvPphsE,30
|
|
3
|
+
oboeru/app.py,sha256=ztvhcmTtfsW8KGLsePSPQ8XV2XrJprqbl8ln_phU2tM,23820
|
|
4
|
+
oboeru/kanjidic2.sqlite,sha256=4IK1XnuLKLtf5-NdtawNR_MWJtjqT_63ujNkKswR_BU,7413760
|
|
5
|
+
oboeru-0.2.0.dist-info/METADATA,sha256=V33UN9YrWE7u_bdcOdhEBTacUcUlos-fXjz9xmOK66Q,1778
|
|
6
|
+
oboeru-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
7
|
+
oboeru-0.2.0.dist-info/entry_points.txt,sha256=zU3N1y93X6DK2K4LMaE-PkoXpRLsvcB6ShrKdvFH64c,43
|
|
8
|
+
oboeru-0.2.0.dist-info/RECORD,,
|