oboeru 0.2.0__tar.gz

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.
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.pyd
5
+ *.DS_Store
6
+ .env
7
+ .venv
8
+ /build/
9
+ dist/
10
+ output/
11
+ *.sqlite
12
+ *.sqlite-*
oboeru-0.2.0/PKG-INFO ADDED
@@ -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
oboeru-0.2.0/README.md ADDED
@@ -0,0 +1,48 @@
1
+ # oboeru (覚える)
2
+
3
+ *Oboeru* is Japanese for "to memorize / learn by heart" — which is exactly what
4
+ this terminal kanji quiz helps you do. Fast, keyboard-driven, and just
5
+ addictive enough to keep you coming back.
6
+
7
+ Built with [textual](https://textual.textualize.io/) on
8
+ [KANJIDIC2](https://www.edrdg.org/wiki/index.php/KANJIDIC_Project) data.
9
+
10
+ ## Features
11
+
12
+ - Rounds of 10 multiple-choice "kanji → meaning" questions per JLPT level (N5–N1)
13
+ - Big half-block kanji art (needs a system CJK font; falls back to plain text)
14
+ - Adaptive selection: unseen and previously missed kanji come back more often
15
+ (Leitner boxes), mastered kanji fade away
16
+ - Sessions are saved: per-kanji progress + session history in a small SQLite
17
+ file; the menu shows lifetime accuracy and mastered count
18
+ - Score with streak bonuses 🔥, animations, and a review list after each round
19
+
20
+ ## Install
21
+
22
+ ```bash
23
+ pip install oboeru
24
+ # or run directly without installing:
25
+ uvx oboeru
26
+ ```
27
+
28
+ The KANJIDIC2 database (built by
29
+ [k2sqlite](https://github.com/trygu/k2sqlite)) is bundled with the package,
30
+ so no setup is needed. Pass `--db /path/to/kanjidic2.sqlite` if you have your
31
+ own copy.
32
+
33
+ ## Run
34
+
35
+ ```bash
36
+ oboeru
37
+ oboeru --help # all options (--db, --kanji-size, --progress)
38
+ ```
39
+
40
+ Keys: `1-4` or `↑/↓` + `enter` answer, `enter` next question, `q` back/quit.
41
+
42
+ Progress is stored in `~/.oboeru/quiz_progress.sqlite` (or next to a custom
43
+ `--db` database) unless `--progress` says otherwise — your streaks survive
44
+ restarts.
45
+
46
+ ## License
47
+
48
+ MIT
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "oboeru"
7
+ version = "0.2.0"
8
+ description = "Addictive terminal kanji quiz (TUI) on KANJIDIC2 data"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "textual>=8.2.8",
14
+ "pillow>=12",
15
+ ]
16
+
17
+ [project.scripts]
18
+ oboeru = "oboeru.app:main"
19
+
20
+ [tool.hatch.build]
21
+ # The KANJIDIC2 database is built into the package before `uv build`
22
+ # (see .github/workflows/oboeru-release.yml); bundle it as package data.
23
+ artifacts = ["src/oboeru/kanjidic2.sqlite"]
@@ -0,0 +1,3 @@
1
+ """oboeru (覚える) — addictive terminal kanji quiz on KANJIDIC2 data."""
2
+
3
+ __version__ = "0.2.0"
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ main()
@@ -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()
Binary file
@@ -0,0 +1,158 @@
1
+ """Tests for the oboeru quiz app.
2
+
3
+ The quiz only depends on the k2sqlite database *contract* (the kanji_seed and
4
+ distractor_pool views), so tests run against a tiny hand-written fixture DB —
5
+ no k2sqlite import needed.
6
+ """
7
+
8
+ import sqlite3
9
+
10
+ import pytest
11
+ from oboeru import app as quiz
12
+
13
+ FIXTURE_SQL = """
14
+ CREATE TABLE kanji_seed(literal, lvl, freq, grade, main_meaning, on_prime, kun_prime);
15
+ CREATE TABLE distractor_pool(lvl, meaning);
16
+ CREATE TABLE kanji_meaning(literal, lang, meaning);
17
+ INSERT INTO kanji_seed VALUES
18
+ ('水', 5, 60, 1, 'water', 'スイ', 'みず'),
19
+ ('日', 5, 1, 1, 'day', 'ニチ・ジツ', 'ひ'),
20
+ ('韓', 2, 445, 8, 'Korea', 'カン', '');
21
+ INSERT INTO distractor_pool VALUES
22
+ (5, 'water'), (5, 'day'), (5, 'sun'), (5, 'Japan'), (2, 'Korea');
23
+ INSERT INTO kanji_meaning VALUES
24
+ ('水', 'en', 'water'),
25
+ ('日', 'en', 'day'), ('日', 'en', 'sun'), ('日', 'en', 'Japan'),
26
+ ('韓', 'en', 'Korea');
27
+ """
28
+
29
+
30
+ @pytest.fixture()
31
+ def quiz_env(tmp_path):
32
+ db = tmp_path / "kanji.sqlite"
33
+ conn = sqlite3.connect(db)
34
+ conn.executescript(FIXTURE_SQL)
35
+ conn.close()
36
+ return db, tmp_path / "progress.sqlite"
37
+
38
+
39
+ def test_selection_weights(quiz_env):
40
+ # Unseen kanji beat seen ones; wrong answers beat mastered boxes
41
+ assert quiz.KanjiDB._weight(None) > quiz.KanjiDB._weight((5, 0, 3))
42
+ assert quiz.KanjiDB._weight((0, 3, 0)) > quiz.KanjiDB._weight((3, 0, 3))
43
+
44
+
45
+ @pytest.mark.asyncio
46
+ async def test_quiz_flow(quiz_env):
47
+ db, progress_path = quiz_env
48
+ app = quiz.KanjiQuizApp(db, progress_path)
49
+ async with app.run_test() as pilot:
50
+ await pilot.pause()
51
+ assert type(app.screen).__name__ == "StartScreen"
52
+
53
+ # Pick N5 (fixture has 水 and 日 at lvl 5)
54
+ await pilot.click("#lvl-5")
55
+ await pilot.pause()
56
+ screen = app.screen
57
+ assert type(screen).__name__ == "QuizScreen"
58
+ assert len(screen.questions) == 2 # min(10, pool size)
59
+ assert len(list(screen.query(".answer"))) == 4
60
+
61
+ # Answer the first question correctly via its button
62
+ q = screen.questions[screen.index]
63
+ await pilot.click(f"#ans-{q.correct_index}")
64
+ await pilot.pause()
65
+ assert screen.correct_count == 1
66
+ assert screen.streak == 1
67
+ assert not screen.query_one("#feedback").has_class("hidden")
68
+
69
+ # Progress was persisted: one correct, zero wrong for that kanji
70
+ row = (
71
+ sqlite3.connect(progress_path)
72
+ .execute(
73
+ "SELECT correct, wrong, box FROM progress WHERE literal=?", (q.literal,)
74
+ )
75
+ .fetchone()
76
+ )
77
+ assert row == (1, 0, 1)
78
+
79
+ # Advance to the next question
80
+ await pilot.press("enter")
81
+ await pilot.pause()
82
+ assert screen.index == 1
83
+
84
+ # Finish the round -> summary screen + session row saved
85
+ q2 = screen.questions[screen.index]
86
+ wrong_index = next(i for i in range(len(q2.choices)) if i != q2.correct_index)
87
+ screen.action_answer(wrong_index)
88
+ await pilot.pause()
89
+ screen.action_next()
90
+ await pilot.pause()
91
+ assert type(app.screen).__name__ == "SummaryScreen"
92
+ sessions = (
93
+ sqlite3.connect(progress_path)
94
+ .execute("SELECT lvl, questions, correct, best_streak FROM session")
95
+ .fetchall()
96
+ )
97
+ assert sessions == [(5, 2, 1, 1)]
98
+
99
+
100
+ @pytest.mark.asyncio
101
+ async def test_arrow_key_navigation(quiz_env):
102
+ db, progress_path = quiz_env
103
+ app = quiz.KanjiQuizApp(db, progress_path)
104
+ async with app.run_test() as pilot:
105
+ await pilot.pause()
106
+ await pilot.click("#lvl-5")
107
+ await pilot.pause()
108
+ screen = app.screen
109
+
110
+ # First option is pre-focused; arrows move the highlight.
111
+ # (disabled buttons are skipped; fixture's 日 question has only 2 choices)
112
+ enabled = [b for b in screen.query(".answer") if not b.disabled]
113
+ assert app.focused is enabled[0]
114
+ await pilot.press("down")
115
+ assert app.focused is enabled[1 % len(enabled)]
116
+ await pilot.press("up")
117
+ assert app.focused is enabled[0]
118
+ # ...and it wraps around
119
+ await pilot.press("up")
120
+ assert app.focused is enabled[-1]
121
+
122
+ # Enter answers the highlighted choice (and does not skip ahead)
123
+ await pilot.press("enter")
124
+ await pilot.pause()
125
+ assert screen.answered
126
+ assert screen.index == 0
127
+
128
+ # After answering, focus is dropped: enter advances to next question
129
+ assert app.focused is None
130
+ await pilot.press("enter")
131
+ await pilot.pause()
132
+ assert screen.index == 1
133
+
134
+
135
+ @pytest.mark.asyncio
136
+ async def test_round_questions_have_unique_valid_choices(quiz_env):
137
+ db, _ = quiz_env
138
+ kanji = quiz.KanjiDB(db)
139
+ questions = kanji.round_questions(5, 10, {})
140
+ assert len({q.literal for q in questions}) == len(questions) # no repeats
141
+ for q in questions:
142
+ assert q.choices[q.correct_index] == q.meaning
143
+ assert len(set(q.choices)) == len(q.choices) # no duplicate choices
144
+
145
+
146
+ def test_kanji_art(quiz_env):
147
+ if quiz._cjk_font(48) is None:
148
+ pytest.skip("no CJK font on this machine")
149
+ art = quiz.kanji_art("水", pixels=24)
150
+ # Big half-block rendering: 12 terminal rows, ink present
151
+ assert len(art.plain.splitlines()) == 12
152
+ assert any(c in art.plain for c in "█▀▄")
153
+
154
+
155
+ def test_kanji_art_fallback_without_font(quiz_env, monkeypatch):
156
+ monkeypatch.setattr(quiz, "CJK_FONT_PATHS", [])
157
+ quiz._cjk_font.cache_clear()
158
+ assert quiz.kanji_art("水") == "水"