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 ADDED
@@ -0,0 +1,3 @@
1
+ """oboeru (覚える) — addictive terminal kanji quiz on KANJIDIC2 data."""
2
+
3
+ __version__ = "0.2.0"
oboeru/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .app import main
2
+
3
+ main()
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()
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ oboeru = oboeru.app:main