@thesimonharms/basa 0.1.0 → 0.1.2

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.
Files changed (42) hide show
  1. package/bin/basa.js +62 -1
  2. package/dist/commands/list.command.js +59 -0
  3. package/dist/commands/new.command.js +24 -0
  4. package/dist/commands/study.command.js +78 -0
  5. package/dist/flashcards/deck.js +207 -0
  6. package/dist/flashcards/halfblock.js +48 -0
  7. package/dist/flashcards/image.js +89 -0
  8. package/dist/flashcards/render.js +110 -0
  9. package/dist/flashcards/sound.js +100 -0
  10. package/dist/flashcards/srs.js +77 -0
  11. package/dist/flashcards/types.js +26 -0
  12. package/dist/providers/AppProvider.js +13 -0
  13. package/dist/tui/CardView.js +228 -0
  14. package/dist/tui/Footer.js +57 -0
  15. package/dist/tui/Header.js +48 -0
  16. package/dist/tui/StudyApp.js +120 -0
  17. package/dist/tui/effects.js +55 -0
  18. package/package.json +5 -5
  19. package/config/app.ts +0 -8
  20. package/src/commands/list.command.ts +0 -65
  21. package/src/commands/new.command.ts +0 -25
  22. package/src/commands/study.command.ts +0 -84
  23. package/src/flashcards/deck.ts +0 -218
  24. package/src/flashcards/halfblock.ts +0 -55
  25. package/src/flashcards/image.ts +0 -97
  26. package/src/flashcards/render.ts +0 -123
  27. package/src/flashcards/sound.ts +0 -115
  28. package/src/flashcards/srs.ts +0 -88
  29. package/src/flashcards/types.ts +0 -85
  30. package/src/providers/AppProvider.ts +0 -14
  31. package/src/tui/CardView.ts +0 -281
  32. package/src/tui/Footer.ts +0 -65
  33. package/src/tui/Header.ts +0 -58
  34. package/src/tui/StudyApp.ts +0 -147
  35. package/src/tui/effects.ts +0 -81
  36. package/test/CardView.test.ts +0 -157
  37. package/test/deck.test.ts +0 -144
  38. package/test/effects.test.ts +0 -51
  39. package/test/halfblock.test.ts +0 -71
  40. package/test/sound.test.ts +0 -27
  41. package/test/srs.test.ts +0 -85
  42. package/test/study.command.test.ts +0 -26
@@ -0,0 +1,100 @@
1
+ import { AudioOut, detectAudio, silentRequested } from '@mudah-cli/audio';
2
+ const SAMPLE_RATE = 44100;
3
+ /** A BasaFx is a tiny sound effect player with two one-shots. */
4
+ export class BasaFx {
5
+ audio = null;
6
+ detection;
7
+ constructor(audio, detection) {
8
+ this.audio = audio;
9
+ this.detection = detection;
10
+ }
11
+ /** Probe + open. Returns a silent instance if no backend is available. */
12
+ static async open() {
13
+ if (silentRequested())
14
+ return new BasaFx(null, null);
15
+ const detection = detectAudio();
16
+ if (detection.backend === 'silent')
17
+ return new BasaFx(null, detection);
18
+ try {
19
+ const audio = await AudioOut.open({ sampleRate: SAMPLE_RATE, channels: 1 });
20
+ return new BasaFx(audio, detection);
21
+ }
22
+ catch {
23
+ return new BasaFx(null, detection);
24
+ }
25
+ }
26
+ get isLive() {
27
+ return this.audio !== null;
28
+ }
29
+ /** Play the "you got it" sound. Grade 2 (Good) and 3 (Easy) → happy ding. */
30
+ async playCorrect(grade) {
31
+ if (this.audio === null)
32
+ return;
33
+ const clip = grade === 3 ? happyChirp() : ding();
34
+ await this.audio.play(clip);
35
+ }
36
+ /** Play the "not quite" sound. Grade 0 (Again) → low buzz. */
37
+ async playIncorrect() {
38
+ if (this.audio === null)
39
+ return;
40
+ await this.audio.play(buzz());
41
+ }
42
+ async dispose() {
43
+ this.audio?.dispose();
44
+ this.audio = null;
45
+ }
46
+ }
47
+ /**
48
+ * A two-note ascending arpeggio (C5 → E5). Quick, bright, low-volume.
49
+ * 0.18s total.
50
+ */
51
+ function ding() {
52
+ const samples = renderSequence([
53
+ { freq: 523.25, durationMs: 90, volume: 0.4 },
54
+ { freq: 659.25, durationMs: 90, volume: 0.4 },
55
+ ]);
56
+ return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
57
+ }
58
+ /** Three ascending notes (C5 → E5 → G5). Triumphant. 0.30s total. */
59
+ function happyChirp() {
60
+ const samples = renderSequence([
61
+ { freq: 523.25, durationMs: 80, volume: 0.45 },
62
+ { freq: 659.25, durationMs: 80, volume: 0.45 },
63
+ { freq: 783.99, durationMs: 140, volume: 0.5 },
64
+ ]);
65
+ return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
66
+ }
67
+ /** Two low square-ish pulses. 0.20s total. */
68
+ function buzz() {
69
+ const samples = renderSequence([
70
+ { freq: 196.0, durationMs: 90, volume: 0.35, pulse: true },
71
+ { freq: 164.81, durationMs: 90, volume: 0.35, pulse: true },
72
+ ]);
73
+ return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
74
+ }
75
+ function renderSequence(notes) {
76
+ const total = notes.reduce((sum, n) => sum + Math.ceil((n.durationMs / 1000) * SAMPLE_RATE), 0);
77
+ const out = new Int16Array(total);
78
+ let cursor = 0;
79
+ for (const note of notes) {
80
+ const frames = Math.ceil((note.durationMs / 1000) * SAMPLE_RATE);
81
+ for (let i = 0; i < frames; i++) {
82
+ const t = i / SAMPLE_RATE;
83
+ const envelope = triangle(i, frames);
84
+ const value = Math.sin(2 * Math.PI * note.freq * t) * envelope * note.volume;
85
+ const square = note.pulse ? (Math.sin(2 * Math.PI * note.freq * t) >= 0 ? 1 : -1) : 0;
86
+ const mixed = note.pulse ? 0.6 * value + 0.4 * square * note.volume * envelope : value;
87
+ out[cursor + i] = Math.max(-1, Math.min(1, mixed)) * 0x7fff;
88
+ }
89
+ cursor += frames;
90
+ }
91
+ return out;
92
+ }
93
+ function triangle(i, total) {
94
+ // Quick attack, slow release: 0..0.05 then linear decay to 0.
95
+ const attack = 0.05;
96
+ const t = i / total;
97
+ if (t < attack)
98
+ return t / attack;
99
+ return Math.max(0, 1 - (t - attack) / (1 - attack));
100
+ }
@@ -0,0 +1,77 @@
1
+ /** A relaxed SM-2 implementation. Reasonable defaults, no anki-parity goal. */
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ export function freshState(now = Date.now()) {
4
+ // `due: null` means "due immediately" — see `isDue`. Using a captured
5
+ // timestamp here is racey when the caller samples `Date.now()` again
6
+ // later (e.g. the list command compares state.due against a `now`
7
+ // sampled before the loop, while the loop's `loadReviewCards` calls
8
+ // freshState() for each card with progressively later timestamps).
9
+ void now;
10
+ return {
11
+ due: null,
12
+ streak: 0,
13
+ ease: 2.5,
14
+ intervalDays: 0,
15
+ reviews: 0,
16
+ lastGrade: null,
17
+ };
18
+ }
19
+ /**
20
+ * Apply a grade to a card's state and return the next state.
21
+ * Pure function — no I/O, no randomness — so it's trivially testable.
22
+ */
23
+ export function grade(state, grade, now = Date.now()) {
24
+ const next = {
25
+ due: state.due,
26
+ streak: state.streak,
27
+ ease: state.ease,
28
+ intervalDays: state.intervalDays,
29
+ reviews: state.reviews + 1,
30
+ lastGrade: grade,
31
+ };
32
+ if (grade === 0) {
33
+ // Again: reset streak, ease drops, see it again in 10 minutes.
34
+ next.streak = 0;
35
+ next.ease = Math.max(1.3, state.ease - 0.2);
36
+ next.intervalDays = 0;
37
+ next.due = now + 10 * 60 * 1000;
38
+ return next;
39
+ }
40
+ next.streak = state.streak + 1;
41
+ // SM-2 ease adjustment for non-failing grades. Hard (1) drops ease slightly;
42
+ // Easy (3) bumps it; Good (2) holds.
43
+ const easeDelta = grade === 1 ? -0.15 : grade === 3 ? 0.15 : 0;
44
+ next.ease = clamp(state.ease + easeDelta, 1.3, 2.8);
45
+ if (next.streak === 1) {
46
+ next.intervalDays = 1;
47
+ }
48
+ else if (next.streak === 2) {
49
+ next.intervalDays = 6;
50
+ }
51
+ else {
52
+ next.intervalDays = Math.max(1, Math.round(state.intervalDays * next.ease));
53
+ }
54
+ next.due = now + next.intervalDays * DAY_MS;
55
+ return next;
56
+ }
57
+ function clamp(value, lo, hi) {
58
+ return Math.max(lo, Math.min(hi, value));
59
+ }
60
+ /** Cards that are due now or earlier. `null` `due` is treated as "due immediately". */
61
+ export function isDue(state, now = Date.now()) {
62
+ if (state.due === null)
63
+ return true;
64
+ return state.due <= now;
65
+ }
66
+ /** Pick the next due card, ordered by oldest-due first, with new cards last. */
67
+ export function pickNext(cards, now = Date.now()) {
68
+ const due = cards.filter((c) => isDue(c.state, now));
69
+ if (due.length === 0)
70
+ return undefined;
71
+ const sorted = [...due].sort((a, b) => {
72
+ const ad = a.state.due ?? 0;
73
+ const bd = b.state.due ?? 0;
74
+ return ad - bd;
75
+ });
76
+ return sorted[0];
77
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Open flashcard format.
3
+ *
4
+ * A deck is a single file (YAML or JSON) with this shape:
5
+ *
6
+ * name: "Spanish 101"
7
+ * description: "First-year Spanish vocabulary"
8
+ * cards:
9
+ * - front: "hola"
10
+ * back: "hello"
11
+ * - front: "perro"
12
+ * back: "dog"
13
+ * - front:
14
+ * text: "ありがとう"
15
+ * image: "./assets/thanks.png" # optional
16
+ * back:
17
+ * text: "thank you"
18
+ *
19
+ * Each side is either a plain string (a single text field) or a list of
20
+ * fields, where each field is `{ text?, image?, audio? }`. The renderer walks
21
+ * the list top-to-bottom, leaving a blank line between fields. An `image`
22
+ * field is rendered with half-block cells (universal truecolor); a `text`
23
+ * field is rendered verbatim. An `audio` field is a relative path to a WAV
24
+ * that the user can play with `p` while reviewing.
25
+ */
26
+ export const GRADE_LABELS = ['Again', 'Hard', 'Good', 'Easy'];
@@ -0,0 +1,13 @@
1
+ import { ServiceProvider } from '@mudah-cli/mudah';
2
+ export default class BasaProvider extends ServiceProvider {
3
+ register() {
4
+ this.app.config().merge('app', {
5
+ name: 'basa',
6
+ env: 'local',
7
+ // Default location for user decks (~/.config/basa/decks).
8
+ decksDir: '~/basa/decks',
9
+ // Optional sound effects: 'on' | 'off' | 'auto'.
10
+ sound: 'auto',
11
+ });
12
+ }
13
+ }
@@ -0,0 +1,228 @@
1
+ import { BaseComponent } from '@mudah-cli/tui';
2
+ import { detectCapabilities } from '@mudah-cli/terminal';
3
+ import { paint, visibleLength } from '@mudah-cli/ui';
4
+ import { renderSide } from '../flashcards/render.js';
5
+ import { confettiRows, shakeRows, typeOnRows, } from './effects.js';
6
+ /**
7
+ * The flashcard face. Renders the front; on space, animates the back in.
8
+ * Keys 1-4 grade. `n` skips (no grade recorded). `enter` submits typed answer.
9
+ *
10
+ * Owns its own animation state — tick() is called by the host every frame.
11
+ */
12
+ export class CardView extends BaseComponent {
13
+ focusable = true;
14
+ typed = '';
15
+ front = { text: [], imageRows: [] };
16
+ back = { text: [], imageRows: [] };
17
+ doneStats = null;
18
+ revealed = false;
19
+ revealAnim = { durationFrames: 8, frame: 0 };
20
+ shake = null;
21
+ confetti = null;
22
+ ctx;
23
+ current;
24
+ options;
25
+ constructor(options) {
26
+ super();
27
+ this.options = options;
28
+ this.ctx = {
29
+ deckPath: options.deckPath,
30
+ cellsWidth: options.cellsWidth,
31
+ cellsHeight: options.cellsHeight,
32
+ imageCache: new Map(),
33
+ };
34
+ }
35
+ /** Update the active card. Resets reveal + animation state. */
36
+ async setCard(card, stats) {
37
+ this.current = card;
38
+ this.revealed = false;
39
+ this.typed = '';
40
+ this.shake = null;
41
+ this.confetti = null;
42
+ this.revealAnim = { durationFrames: 8, frame: 0 };
43
+ if (card === undefined) {
44
+ this.doneStats = stats ?? null;
45
+ this.front = { text: this.doneViewText(), imageRows: [] };
46
+ this.back = { text: [], imageRows: [] };
47
+ this.options.onDone?.();
48
+ return;
49
+ }
50
+ this.doneStats = null;
51
+ this.front = await renderSide(card.card.front, this.ctx);
52
+ this.back = await renderSide(card.card.back, this.ctx);
53
+ }
54
+ doneViewText() {
55
+ if (this.doneStats === null) {
56
+ return ['All caught up!', 'Press Esc to quit.'];
57
+ }
58
+ const s = this.doneStats;
59
+ return [
60
+ '',
61
+ ' ╭───────────────────────────────────────╮',
62
+ ' │ │',
63
+ ' │ session complete! │',
64
+ ' │ │',
65
+ ` │ reviewed ${pad(s.reviewed, 3)} cards │`,
66
+ ` │ again ${pad(s.again, 3)} hard ${pad(s.hard, 3)} good ${pad(s.good, 3)} easy ${pad(s.easy, 3)} │`,
67
+ ' │ │',
68
+ ' │ press esc to exit │',
69
+ ' │ │',
70
+ ' ╰───────────────────────────────────────╯',
71
+ '',
72
+ ];
73
+ }
74
+ /** Drive animations. Called by the host once per repaint. */
75
+ tick() {
76
+ if (this.revealed && this.revealAnim.frame < this.revealAnim.durationFrames) {
77
+ this.revealAnim.frame++;
78
+ }
79
+ if (this.shake !== null && this.shake.frame < this.shake.durationFrames) {
80
+ this.shake.frame++;
81
+ if (this.shake.frame >= this.shake.durationFrames)
82
+ this.shake = null;
83
+ }
84
+ if (this.confetti !== null && this.confetti.frame < this.confetti.durationFrames) {
85
+ this.confetti.frame++;
86
+ if (this.confetti.frame >= this.confetti.durationFrames)
87
+ this.confetti = null;
88
+ }
89
+ }
90
+ /** Resize the rendering area (after a terminal resize). */
91
+ resize(cellsWidth, cellsHeight) {
92
+ this.ctx.cellsWidth = cellsWidth;
93
+ this.ctx.cellsHeight = cellsHeight;
94
+ this.ctx.imageCache.clear();
95
+ if (this.current !== undefined) {
96
+ // Re-render in the background. Until the new rows arrive, the existing
97
+ // rows still display — no flicker, no jump.
98
+ void this.setCard(this.current);
99
+ }
100
+ }
101
+ onKey(event) {
102
+ if (event.name === 'space' || event.name === 'enter') {
103
+ this.revealed = true;
104
+ this.revealAnim = { durationFrames: 8, frame: 0 };
105
+ return true;
106
+ }
107
+ if (event.name === 'backspace') {
108
+ this.typed = this.typed.slice(0, -1);
109
+ return true;
110
+ }
111
+ if (this.revealed) {
112
+ switch (event.name) {
113
+ case '1':
114
+ this.grade(0);
115
+ return true;
116
+ case '2':
117
+ this.grade(1);
118
+ return true;
119
+ case '3':
120
+ this.grade(2);
121
+ return true;
122
+ case '4':
123
+ this.grade(3);
124
+ return true;
125
+ }
126
+ }
127
+ if (event.name === 'n') {
128
+ this.options.onSkip();
129
+ return true;
130
+ }
131
+ if (event.ch !== undefined && event.ch >= ' ' && event.ch !== 'n' && (event.ch < '0' || event.ch > '9')) {
132
+ this.typed += event.ch;
133
+ return true;
134
+ }
135
+ return false;
136
+ }
137
+ grade(g) {
138
+ this.options.onGraded(g, this.typed);
139
+ if (g === 0) {
140
+ this.shake = { durationFrames: 10, frame: 0 };
141
+ }
142
+ else if (g === 3) {
143
+ this.confetti = {
144
+ frame: 0,
145
+ durationFrames: 24,
146
+ seed: Math.floor(Math.random() * 1e6),
147
+ width: this.options.cellsWidth,
148
+ palette: ['\x1b[38;5;213m', '\x1b[38;5;215m', '\x1b[38;5;220m', '\x1b[38;5;156m', '\x1b[38;5;123m'],
149
+ };
150
+ }
151
+ this.typed = '';
152
+ }
153
+ render() {
154
+ const out = [];
155
+ // Header line: prompt + type input
156
+ const lvl = detectCapabilities().colorLevel;
157
+ const promptText = this.revealed
158
+ ? paint('#fbbf24', 'Type your answer, then press 1-4 to grade', lvl)
159
+ : paint('#94a3b8', 'Press Space to reveal', lvl);
160
+ const typed = this.typed.length > 0 ? paint('#e2e8f0', ` > ${this.typed}▏`, lvl) : '';
161
+ out.push(centerLine(`${promptText}${typed}`, this.options.cellsWidth));
162
+ // Spacer
163
+ out.push('');
164
+ // Card front
165
+ const frontRows = this.front.text;
166
+ const centeredFront = frontRows.map((row) => centerLine(row, this.options.cellsWidth));
167
+ out.push(...this.applyEffects(centeredFront, 'front'));
168
+ // Spacer
169
+ out.push('');
170
+ // Divider
171
+ if (this.revealed) {
172
+ out.push(centerLine(paint('#475569', '─'.repeat(Math.min(60, this.options.cellsWidth - 4)), lvl), this.options.cellsWidth));
173
+ out.push('');
174
+ const backRows = this.back.text;
175
+ const typedBack = this.revealed ? typeOnRows(this.revealAnim, backRows) : [];
176
+ const centeredBack = typedBack.map((row) => centerLine(row, this.options.cellsWidth));
177
+ out.push(...this.applyEffects(centeredBack, 'back'));
178
+ // Hint, if any
179
+ if (this.current?.card.hint !== undefined && this.revealed) {
180
+ out.push('');
181
+ out.push(centerLine(paint('#64748b', `hint: ${this.current.card.hint}`, lvl), this.options.cellsWidth));
182
+ }
183
+ }
184
+ // Image rows (always rendered below the text).
185
+ const imageRows = this.revealed
186
+ ? [...this.front.imageRows, ...this.back.imageRows]
187
+ : this.front.imageRows;
188
+ for (const row of imageRows) {
189
+ out.push(centerLine(row, this.options.cellsWidth));
190
+ }
191
+ // Confetti overlay
192
+ if (this.confetti !== null) {
193
+ const overlay = confettiRows(this.confetti, Math.min(8, this.options.cellsHeight));
194
+ for (let i = 0; i < overlay.length; i++) {
195
+ out[out.length - overlay.length + i] = overlay[i] ?? '';
196
+ }
197
+ }
198
+ // Pad / truncate to fit the cell height so the screen layout is stable.
199
+ while (out.length < this.options.cellsHeight)
200
+ out.push('');
201
+ if (out.length > this.options.cellsHeight)
202
+ out.length = this.options.cellsHeight;
203
+ return out;
204
+ }
205
+ applyEffects(rows, seedKey) {
206
+ let out = rows;
207
+ if (this.shake !== null) {
208
+ out = shakeRows(this.shake, out, hashString(`${seedKey}:${this.current?.card.front ?? ''}`));
209
+ }
210
+ return out;
211
+ }
212
+ }
213
+ function centerLine(text, width) {
214
+ const visible = visibleLength(text);
215
+ if (visible >= width)
216
+ return text;
217
+ const pad = Math.floor((width - visible) / 2);
218
+ return ' '.repeat(pad) + text;
219
+ }
220
+ function hashString(s) {
221
+ let h = 0;
222
+ for (let i = 0; i < s.length; i++)
223
+ h = (h * 31 + s.charCodeAt(i)) | 0;
224
+ return h;
225
+ }
226
+ function pad(n, width) {
227
+ return String(n).padStart(width, ' ');
228
+ }
@@ -0,0 +1,57 @@
1
+ import { BaseComponent } from '@mudah-cli/tui';
2
+ import { detectCapabilities } from '@mudah-cli/terminal';
3
+ import { paint } from '@mudah-cli/ui';
4
+ const KEYS = [
5
+ { key: '1', label: 'Again', tone: 'bad' },
6
+ { key: '2', label: 'Hard', tone: 'meh' },
7
+ { key: '3', label: 'Good', tone: 'good' },
8
+ { key: '4', label: 'Easy', tone: 'good' },
9
+ ];
10
+ const TONES = {
11
+ good: '#4ade80',
12
+ meh: '#fbbf24',
13
+ bad: '#f87171',
14
+ neutral: '#94a3b8',
15
+ };
16
+ /** The key legend. Centered, three rows: title, four key chips, hints. */
17
+ export class Footer extends BaseComponent {
18
+ focusable = false;
19
+ width;
20
+ constructor(width) {
21
+ super();
22
+ this.width = width;
23
+ }
24
+ resize(width) {
25
+ this.width = width;
26
+ }
27
+ render() {
28
+ const lvl = detectCapabilities().colorLevel;
29
+ const chips = KEYS.map((k) => {
30
+ const color = TONES[k.tone];
31
+ return `${paint('#0f172a', ` ${k.key} `, lvl)}${paint(color, ` ${k.label} `, lvl)}`;
32
+ }).join(' ');
33
+ const row1 = centerLine(chips, this.width);
34
+ const row2 = centerLine(paint('#64748b', 'space/enter: reveal n: skip esc: quit', lvl), this.width);
35
+ return ['', row1, row2];
36
+ }
37
+ }
38
+ function centerLine(text, width) {
39
+ return ' '.repeat(Math.max(0, Math.floor((width - visibleOf(text)) / 2))) + text;
40
+ }
41
+ function visibleOf(text) {
42
+ let visible = 0;
43
+ let inEscape = false;
44
+ for (const ch of text) {
45
+ if (ch === '\x1b') {
46
+ inEscape = true;
47
+ continue;
48
+ }
49
+ if (inEscape) {
50
+ if (ch === 'm')
51
+ inEscape = false;
52
+ continue;
53
+ }
54
+ visible++;
55
+ }
56
+ return visible;
57
+ }
@@ -0,0 +1,48 @@
1
+ import { BaseComponent } from '@mudah-cli/tui';
2
+ import { detectCapabilities } from '@mudah-cli/terminal';
3
+ import { paint } from '@mudah-cli/ui';
4
+ /** Deck name + progress bar. One row, centered. */
5
+ export class Header extends BaseComponent {
6
+ focusable = false;
7
+ options;
8
+ constructor(options) {
9
+ super();
10
+ this.options = options;
11
+ }
12
+ update(options) {
13
+ Object.assign(this.options, options);
14
+ }
15
+ render() {
16
+ const lvl = detectCapabilities().colorLevel;
17
+ const barWidth = Math.max(10, Math.min(40, this.options.width - 30));
18
+ const ratio = this.options.total > 0 ? this.options.reviewed / this.options.total : 0;
19
+ const filled = Math.round(ratio * barWidth);
20
+ const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
21
+ const left = paint('#e2e8f0', this.options.deckName, lvl);
22
+ const right = paint('#94a3b8', ` ${this.options.reviewed}/${this.options.total}`, lvl);
23
+ const progress = paint(ratio >= 1 ? '#4ade80' : '#60a5fa', bar, lvl);
24
+ const line = `${left} ${progress}${right}`;
25
+ return [centerLine(line, this.options.width)];
26
+ }
27
+ }
28
+ function centerLine(text, width) {
29
+ // Use a simple visible-length estimate: count chars, ignore ANSI.
30
+ return ' '.repeat(Math.max(0, Math.floor((width - visibleOf(text)) / 2))) + text;
31
+ }
32
+ function visibleOf(text) {
33
+ let visible = 0;
34
+ let inEscape = false;
35
+ for (const ch of text) {
36
+ if (ch === '\x1b') {
37
+ inEscape = true;
38
+ continue;
39
+ }
40
+ if (inEscape) {
41
+ if (ch === 'm')
42
+ inEscape = false;
43
+ continue;
44
+ }
45
+ visible++;
46
+ }
47
+ return visible;
48
+ }
@@ -0,0 +1,120 @@
1
+ import { Container } from '@mudah-cli/tui';
2
+ import { grade, pickNext } from '../flashcards/srs.js';
3
+ import { saveReviewCards } from '../flashcards/deck.js';
4
+ import { BasaFx } from '../flashcards/sound.js';
5
+ import { CardView } from './CardView.js';
6
+ import { Header } from './Header.js';
7
+ import { Footer } from './Footer.js';
8
+ /**
9
+ * The study session. Owns SRS state, the focused card, the session stats.
10
+ * Exposes a `Container` for the TUI program to mount, plus `tick()` and
11
+ * `resize()` methods the host calls every frame and on terminal resize.
12
+ */
13
+ export class StudyApp {
14
+ root;
15
+ cardView;
16
+ header;
17
+ footer;
18
+ cards;
19
+ current;
20
+ stats = { reviewed: 0, again: 0, hard: 0, good: 0, easy: 0 };
21
+ width;
22
+ height;
23
+ options;
24
+ constructor(options) {
25
+ this.options = options;
26
+ this.cards = options.cards;
27
+ this.width = options.width;
28
+ this.height = options.height;
29
+ const cardHeight = Math.max(8, options.height - 8);
30
+ this.cardView = new CardView({
31
+ deckPath: options.deckPath,
32
+ cellsWidth: options.width,
33
+ cellsHeight: cardHeight,
34
+ onGraded: (g, typed) => void this.handleGrade(g, typed),
35
+ onSkip: () => this.handleSkip(),
36
+ });
37
+ this.header = new Header({
38
+ deckName: options.deck.name,
39
+ reviewed: 0,
40
+ total: this.cards.length,
41
+ width: options.width,
42
+ });
43
+ this.footer = new Footer(options.width);
44
+ this.root = new Container().add(this.header).add(this.cardView).add(this.footer);
45
+ void this.advance();
46
+ }
47
+ /** Drive animations. Called by the host on every frame. */
48
+ tick() {
49
+ this.cardView.tick();
50
+ }
51
+ /** Apply a terminal resize. */
52
+ resize(width, height) {
53
+ this.width = width;
54
+ this.height = height;
55
+ this.header.update({
56
+ deckName: this.options.deck.name,
57
+ reviewed: this.stats.reviewed,
58
+ total: this.cards.length,
59
+ width,
60
+ });
61
+ this.footer.resize(width);
62
+ const cardHeight = Math.max(8, height - 8);
63
+ this.cardView.resize(width, cardHeight);
64
+ }
65
+ /** Persist state on shutdown. Safe to call multiple times. */
66
+ async persist() {
67
+ await saveReviewCards(this.options.deckPath, this.cards).catch(() => { });
68
+ }
69
+ async advance() {
70
+ this.current = pickNext(this.cards, Date.now());
71
+ if (this.current === undefined) {
72
+ await this.cardView.setCard(undefined, this.stats);
73
+ return;
74
+ }
75
+ await this.cardView.setCard(this.current);
76
+ }
77
+ async handleGrade(gradeValue, _typed) {
78
+ if (this.current === undefined)
79
+ return;
80
+ const idx = this.cards.indexOf(this.current);
81
+ if (idx < 0)
82
+ return;
83
+ const next = grade(this.current.state, gradeValue);
84
+ this.cards[idx] = { card: this.current.card, state: next };
85
+ this.stats = {
86
+ reviewed: this.stats.reviewed + 1,
87
+ again: this.stats.again + (gradeValue === 0 ? 1 : 0),
88
+ hard: this.stats.hard + (gradeValue === 1 ? 1 : 0),
89
+ good: this.stats.good + (gradeValue === 2 ? 1 : 0),
90
+ easy: this.stats.easy + (gradeValue === 3 ? 1 : 0),
91
+ };
92
+ this.header.update({
93
+ deckName: this.options.deck.name,
94
+ reviewed: this.stats.reviewed,
95
+ total: this.cards.length,
96
+ width: this.width,
97
+ });
98
+ if (gradeValue === 0) {
99
+ await this.options.fx.playIncorrect();
100
+ }
101
+ else {
102
+ await this.options.fx.playCorrect(gradeValue);
103
+ }
104
+ // Persist after every grade so a crash doesn't lose progress.
105
+ await saveReviewCards(this.options.deckPath, this.cards).catch(() => { });
106
+ await this.advance();
107
+ }
108
+ handleSkip() {
109
+ if (this.current === undefined)
110
+ return;
111
+ const idx = this.cards.indexOf(this.current);
112
+ if (idx < 0)
113
+ return;
114
+ this.cards[idx] = {
115
+ card: this.current.card,
116
+ state: { ...this.current.state, due: Date.now() + 30_000 },
117
+ };
118
+ void this.advance();
119
+ }
120
+ }