@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
@@ -1,123 +0,0 @@
1
- import { existsSync } from 'node:fs';
2
- import { dirname, isAbsolute, resolve } from 'node:path';
3
- import type { Field, Side } from './types.js';
4
- import { isImagePath, loadRgba } from './image.js';
5
- import { halfBlockLines } from './halfblock.js';
6
-
7
- export interface RenderContext {
8
- deckPath: string;
9
- /** Max width in cells available for the card face. */
10
- cellsWidth: number;
11
- /** Max height in cells. */
12
- cellsHeight: number;
13
- /** Reused so we don't reload + re-resize the same image every frame. */
14
- imageCache: Map<string, Promise<string[] | null>>;
15
- }
16
-
17
- export interface RenderedSide {
18
- /** Plain text rows (no images). Always present, useful for animations. */
19
- text: string[];
20
- /** Pre-rendered image rows (already half-block ANSI strings). Empty if no images. */
21
- imageRows: string[];
22
- }
23
-
24
- /**
25
- * Render a `Side` to text + image rows. Text rows are returned synchronously
26
- * (so card reveals are instant). Image rows resolve asynchronously and
27
- * populate the cache.
28
- */
29
- export async function renderSide(side: Side, ctx: RenderContext): Promise<RenderedSide> {
30
- const fields = normalizeSide(side);
31
- const textRows: string[] = [];
32
- const imageRows: string[] = [];
33
-
34
- for (const field of fields) {
35
- if (field.text !== undefined) {
36
- for (const row of wrapText(field.text, ctx.cellsWidth)) {
37
- textRows.push(row);
38
- }
39
- }
40
- if (field.image !== undefined) {
41
- const rendered = await renderImageField(field.image, ctx);
42
- if (rendered !== null) {
43
- // Leave a blank line before an image if we already have text.
44
- if (textRows.length > 0) textRows.push('');
45
- imageRows.push(...rendered);
46
- } else {
47
- textRows.push(`[image missing: ${field.image}]`);
48
- }
49
- }
50
- }
51
-
52
- return { text: textRows, imageRows };
53
- }
54
-
55
- function normalizeSide(side: Side): Field[] {
56
- if (typeof side === 'string') return [{ text: side }];
57
- return side;
58
- }
59
-
60
- function wrapText(text: string, width: number): string[] {
61
- if (width <= 0) return [text];
62
- const out: string[] = [];
63
- for (const paragraph of text.split('\n')) {
64
- if (paragraph.length === 0) {
65
- out.push('');
66
- continue;
67
- }
68
- // Greedy wrap on whitespace; preserves CJK by breaking on any char.
69
- const isWide = /[\u3000-\u9fff\uff00-\uffef]/.test(paragraph);
70
- if (isWide) {
71
- for (let i = 0; i < paragraph.length; i += width) {
72
- out.push(paragraph.slice(i, i + width));
73
- }
74
- } else {
75
- const words = paragraph.split(/(\s+)/);
76
- let line = '';
77
- for (const word of words) {
78
- if (line.length + word.length > width && line.length > 0) {
79
- out.push(line);
80
- line = word.trimStart();
81
- if (line.length > width) {
82
- // Long word: hard-split.
83
- for (let i = 0; i < line.length; i += width) {
84
- out.push(line.slice(i, i + width));
85
- }
86
- line = '';
87
- }
88
- } else {
89
- line += word;
90
- }
91
- }
92
- if (line.length > 0) out.push(line);
93
- }
94
- }
95
- return out;
96
- }
97
-
98
- async function renderImageField(ref: string, ctx: RenderContext): Promise<string[] | null> {
99
- const cached = ctx.imageCache.get(ref);
100
- if (cached !== undefined) return cached;
101
- const promise = (async () => {
102
- if (!isImagePath(ref)) return null;
103
- const resolved = resolveRef(ctx.deckPath, ref);
104
- if (!existsSync(resolved)) return null;
105
- const size = pickImageSize(ctx.cellsWidth, ctx.cellsHeight);
106
- const rgba = await loadRgba(resolved, size.width, size.height);
107
- if (rgba === null) return null;
108
- return halfBlockLines(rgba);
109
- })();
110
- ctx.imageCache.set(ref, promise);
111
- return promise;
112
- }
113
-
114
- function resolveRef(deckPath: string, ref: string): string {
115
- if (ref.startsWith('~')) return ref.replace(/^~/, process.env.HOME ?? '');
116
- if (isAbsolute(ref)) return ref;
117
- return resolve(dirname(deckPath), ref);
118
- }
119
-
120
- function pickImageSize(cellsWidth: number, cellsHeight: number): { width: number; height: number } {
121
- // ImageMagick does the resize for us, so we just hand it the cell size.
122
- return { width: cellsWidth, height: cellsHeight * 2 };
123
- }
@@ -1,115 +0,0 @@
1
- import { AudioOut, detectAudio, silentRequested, type AudioClip, type AudioDetection } from '@mudah-cli/audio';
2
- import type { Grade } from './types.js';
3
-
4
- const SAMPLE_RATE = 44100;
5
-
6
- /** A BasaFx is a tiny sound effect player with two one-shots. */
7
- export class BasaFx {
8
- private audio: AudioOut | null = null;
9
- readonly detection: AudioDetection | null;
10
-
11
- private constructor(audio: AudioOut | null, detection: AudioDetection | null) {
12
- this.audio = audio;
13
- this.detection = detection;
14
- }
15
-
16
- /** Probe + open. Returns a silent instance if no backend is available. */
17
- static async open(): Promise<BasaFx> {
18
- if (silentRequested()) return new BasaFx(null, null);
19
- const detection = detectAudio();
20
- if (detection.backend === 'silent') return new BasaFx(null, detection);
21
- try {
22
- const audio = await AudioOut.open({ sampleRate: SAMPLE_RATE, channels: 1 });
23
- return new BasaFx(audio, detection);
24
- } catch {
25
- return new BasaFx(null, detection);
26
- }
27
- }
28
-
29
- get isLive(): boolean {
30
- return this.audio !== null;
31
- }
32
-
33
- /** Play the "you got it" sound. Grade 2 (Good) and 3 (Easy) → happy ding. */
34
- async playCorrect(grade: Grade): Promise<void> {
35
- if (this.audio === null) return;
36
- const clip = grade === 3 ? happyChirp() : ding();
37
- await this.audio.play(clip);
38
- }
39
-
40
- /** Play the "not quite" sound. Grade 0 (Again) → low buzz. */
41
- async playIncorrect(): Promise<void> {
42
- if (this.audio === null) return;
43
- await this.audio.play(buzz());
44
- }
45
-
46
- async dispose(): Promise<void> {
47
- this.audio?.dispose();
48
- this.audio = null;
49
- }
50
- }
51
-
52
- /**
53
- * A two-note ascending arpeggio (C5 → E5). Quick, bright, low-volume.
54
- * 0.18s total.
55
- */
56
- function ding(): AudioClip {
57
- const samples = renderSequence([
58
- { freq: 523.25, durationMs: 90, volume: 0.4 },
59
- { freq: 659.25, durationMs: 90, volume: 0.4 },
60
- ]);
61
- return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
62
- }
63
-
64
- /** Three ascending notes (C5 → E5 → G5). Triumphant. 0.30s total. */
65
- function happyChirp(): AudioClip {
66
- const samples = renderSequence([
67
- { freq: 523.25, durationMs: 80, volume: 0.45 },
68
- { freq: 659.25, durationMs: 80, volume: 0.45 },
69
- { freq: 783.99, durationMs: 140, volume: 0.5 },
70
- ]);
71
- return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
72
- }
73
-
74
- /** Two low square-ish pulses. 0.20s total. */
75
- function buzz(): AudioClip {
76
- const samples = renderSequence([
77
- { freq: 196.0, durationMs: 90, volume: 0.35, pulse: true },
78
- { freq: 164.81, durationMs: 90, volume: 0.35, pulse: true },
79
- ]);
80
- return { samples, sampleRate: SAMPLE_RATE, channels: 1 };
81
- }
82
-
83
- interface Note {
84
- freq: number;
85
- durationMs: number;
86
- volume: number;
87
- pulse?: boolean;
88
- }
89
-
90
- function renderSequence(notes: Note[]): Int16Array {
91
- const total = notes.reduce((sum, n) => sum + Math.ceil((n.durationMs / 1000) * SAMPLE_RATE), 0);
92
- const out = new Int16Array(total);
93
- let cursor = 0;
94
- for (const note of notes) {
95
- const frames = Math.ceil((note.durationMs / 1000) * SAMPLE_RATE);
96
- for (let i = 0; i < frames; i++) {
97
- const t = i / SAMPLE_RATE;
98
- const envelope = triangle(i, frames);
99
- const value = Math.sin(2 * Math.PI * note.freq * t) * envelope * note.volume;
100
- const square = note.pulse ? (Math.sin(2 * Math.PI * note.freq * t) >= 0 ? 1 : -1) : 0;
101
- const mixed = note.pulse ? 0.6 * value + 0.4 * square * note.volume * envelope : value;
102
- out[cursor + i] = Math.max(-1, Math.min(1, mixed)) * 0x7fff;
103
- }
104
- cursor += frames;
105
- }
106
- return out;
107
- }
108
-
109
- function triangle(i: number, total: number): number {
110
- // Quick attack, slow release: 0..0.05 then linear decay to 0.
111
- const attack = 0.05;
112
- const t = i / total;
113
- if (t < attack) return t / attack;
114
- return Math.max(0, 1 - (t - attack) / (1 - attack));
115
- }
@@ -1,88 +0,0 @@
1
- import type { Grade, SrsState } from './types.js';
2
-
3
- /** A relaxed SM-2 implementation. Reasonable defaults, no anki-parity goal. */
4
-
5
- const DAY_MS = 24 * 60 * 60 * 1000;
6
-
7
- export function freshState(now: number = Date.now()): SrsState {
8
- // `due: null` means "due immediately" — see `isDue`. Using a captured
9
- // timestamp here is racey when the caller samples `Date.now()` again
10
- // later (e.g. the list command compares state.due against a `now`
11
- // sampled before the loop, while the loop's `loadReviewCards` calls
12
- // freshState() for each card with progressively later timestamps).
13
- void now;
14
- return {
15
- due: null,
16
- streak: 0,
17
- ease: 2.5,
18
- intervalDays: 0,
19
- reviews: 0,
20
- lastGrade: null,
21
- };
22
- }
23
-
24
- /**
25
- * Apply a grade to a card's state and return the next state.
26
- * Pure function — no I/O, no randomness — so it's trivially testable.
27
- */
28
- export function grade(state: SrsState, grade: Grade, now: number = Date.now()): SrsState {
29
- const next: SrsState = {
30
- due: state.due,
31
- streak: state.streak,
32
- ease: state.ease,
33
- intervalDays: state.intervalDays,
34
- reviews: state.reviews + 1,
35
- lastGrade: grade,
36
- };
37
-
38
- if (grade === 0) {
39
- // Again: reset streak, ease drops, see it again in 10 minutes.
40
- next.streak = 0;
41
- next.ease = Math.max(1.3, state.ease - 0.2);
42
- next.intervalDays = 0;
43
- next.due = now + 10 * 60 * 1000;
44
- return next;
45
- }
46
-
47
- next.streak = state.streak + 1;
48
- // SM-2 ease adjustment for non-failing grades. Hard (1) drops ease slightly;
49
- // Easy (3) bumps it; Good (2) holds.
50
- const easeDelta = grade === 1 ? -0.15 : grade === 3 ? 0.15 : 0;
51
- next.ease = clamp(state.ease + easeDelta, 1.3, 2.8);
52
-
53
- if (next.streak === 1) {
54
- next.intervalDays = 1;
55
- } else if (next.streak === 2) {
56
- next.intervalDays = 6;
57
- } else {
58
- next.intervalDays = Math.max(1, Math.round(state.intervalDays * next.ease));
59
- }
60
-
61
- next.due = now + next.intervalDays * DAY_MS;
62
- return next;
63
- }
64
-
65
- function clamp(value: number, lo: number, hi: number): number {
66
- return Math.max(lo, Math.min(hi, value));
67
- }
68
-
69
- /** Cards that are due now or earlier. `null` `due` is treated as "due immediately". */
70
- export function isDue(state: SrsState, now: number = Date.now()): boolean {
71
- if (state.due === null) return true;
72
- return state.due <= now;
73
- }
74
-
75
- /** Pick the next due card, ordered by oldest-due first, with new cards last. */
76
- export function pickNext<T extends { state: SrsState }>(
77
- cards: readonly T[],
78
- now: number = Date.now(),
79
- ): T | undefined {
80
- const due = cards.filter((c) => isDue(c.state, now));
81
- if (due.length === 0) return undefined;
82
- const sorted = [...due].sort((a, b) => {
83
- const ad = a.state.due ?? 0;
84
- const bd = b.state.due ?? 0;
85
- return ad - bd;
86
- });
87
- return sorted[0];
88
- }
@@ -1,85 +0,0 @@
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
-
27
- export interface Field {
28
- text?: string;
29
- image?: string;
30
- audio?: string;
31
- }
32
-
33
- export type Side = string | Field[];
34
-
35
- export interface Card {
36
- front: Side;
37
- back: Side;
38
- /**
39
- * Optional per-card hints. The SRS engine reads `tags` for filtering; the
40
- * UI reads `hint` to show a small prompt after a wrong answer.
41
- */
42
- tags?: string[];
43
- hint?: string;
44
- }
45
-
46
- export interface Deck {
47
- name: string;
48
- description?: string;
49
- cards: Card[];
50
- }
51
-
52
- export interface SrsState {
53
- /** Next due time, ms since epoch. `null` means "not started". */
54
- due: number | null;
55
- /** Number of successful reviews in a row. Resets to 0 on `again`. */
56
- streak: number;
57
- /** SM-2 ease factor, 1.3 .. 2.8. */
58
- ease: number;
59
- /** Current interval in days. */
60
- intervalDays: number;
61
- /** Total times this card has been shown. */
62
- reviews: number;
63
- /** Last grade given (0..3). */
64
- lastGrade: 0 | 1 | 2 | 3 | null;
65
- }
66
-
67
- /** A card together with its review state. */
68
- export interface ReviewCard {
69
- card: Card;
70
- state: SrsState;
71
- }
72
-
73
- /** SM-2-style grades. */
74
- export type Grade = 0 | 1 | 2 | 3;
75
-
76
- export const GRADE_LABELS: readonly string[] = ['Again', 'Hard', 'Good', 'Easy'];
77
-
78
- /**
79
- * What a user actually answered. The text input is free-form; the rating bar
80
- * is graded. We capture both so the UI can show "you typed X, the answer was Y".
81
- */
82
- export interface AnswerRecord {
83
- typed: string;
84
- grade: Grade;
85
- }
@@ -1,14 +0,0 @@
1
- import { ServiceProvider } from '@mudah-cli/mudah';
2
-
3
- export default class BasaProvider extends ServiceProvider {
4
- register(): void {
5
- this.app.config().merge('app', {
6
- name: 'basa',
7
- env: 'local',
8
- // Default location for user decks (~/.config/basa/decks).
9
- decksDir: '~/basa/decks',
10
- // Optional sound effects: 'on' | 'off' | 'auto'.
11
- sound: 'auto',
12
- });
13
- }
14
- }
@@ -1,281 +0,0 @@
1
- import { BaseComponent } from '@mudah-cli/tui';
2
- import { detectCapabilities, type KeyEvent } from '@mudah-cli/terminal';
3
- import { paint, visibleLength } from '@mudah-cli/ui';
4
- import type { Grade, ReviewCard } from '../flashcards/types.js';
5
- import { renderSide, type RenderContext, type RenderedSide } from '../flashcards/render.js';
6
- import {
7
- confettiRows,
8
- shakeRows,
9
- typeOnRows,
10
- type ConfettiSpec,
11
- type ShakeState,
12
- type TypeOnState,
13
- } from './effects.js';
14
-
15
- export interface CardViewOptions {
16
- deckPath: string;
17
- cellsWidth: number;
18
- cellsHeight: number;
19
- onGraded: (grade: Grade, typed: string) => void;
20
- onSkip: () => void;
21
- onDone?: () => void;
22
- }
23
-
24
- export interface SessionStats {
25
- reviewed: number;
26
- again: number;
27
- hard: number;
28
- good: number;
29
- easy: number;
30
- }
31
-
32
- interface EffectFrame {
33
- /** Centered, padded text content. */
34
- rows: string[];
35
- /** Image rows to draw below the text. */
36
- imageRows: string[];
37
- }
38
-
39
- /**
40
- * The flashcard face. Renders the front; on space, animates the back in.
41
- * Keys 1-4 grade. `n` skips (no grade recorded). `enter` submits typed answer.
42
- *
43
- * Owns its own animation state — tick() is called by the host every frame.
44
- */
45
- export class CardView extends BaseComponent {
46
- readonly focusable = true;
47
- private typed = '';
48
-
49
- private front: RenderedSide = { text: [], imageRows: [] };
50
- private back: RenderedSide = { text: [], imageRows: [] };
51
- private doneStats: SessionStats | null = null;
52
-
53
- private revealed = false;
54
- private revealAnim: TypeOnState = { durationFrames: 8, frame: 0 };
55
- private shake: ShakeState | null = null;
56
- private confetti: ConfettiSpec | null = null;
57
-
58
- private readonly ctx: RenderContext;
59
- private current: ReviewCard | undefined;
60
-
61
- private readonly options: CardViewOptions;
62
-
63
- constructor(options: CardViewOptions) {
64
- super();
65
- this.options = options;
66
- this.ctx = {
67
- deckPath: options.deckPath,
68
- cellsWidth: options.cellsWidth,
69
- cellsHeight: options.cellsHeight,
70
- imageCache: new Map(),
71
- };
72
- }
73
-
74
- /** Update the active card. Resets reveal + animation state. */
75
- async setCard(card: ReviewCard | undefined, stats?: SessionStats): Promise<void> {
76
- this.current = card;
77
- this.revealed = false;
78
- this.typed = '';
79
- this.shake = null;
80
- this.confetti = null;
81
- this.revealAnim = { durationFrames: 8, frame: 0 };
82
- if (card === undefined) {
83
- this.doneStats = stats ?? null;
84
- this.front = { text: this.doneViewText(), imageRows: [] };
85
- this.back = { text: [], imageRows: [] };
86
- this.options.onDone?.();
87
- return;
88
- }
89
- this.doneStats = null;
90
- this.front = await renderSide(card.card.front, this.ctx);
91
- this.back = await renderSide(card.card.back, this.ctx);
92
- }
93
-
94
- private doneViewText(): string[] {
95
- if (this.doneStats === null) {
96
- return ['All caught up!', 'Press Esc to quit.'];
97
- }
98
- const s = this.doneStats;
99
- return [
100
- '',
101
- ' ╭───────────────────────────────────────╮',
102
- ' │ │',
103
- ' │ session complete! │',
104
- ' │ │',
105
- ` │ reviewed ${pad(s.reviewed, 3)} cards │`,
106
- ` │ again ${pad(s.again, 3)} hard ${pad(s.hard, 3)} good ${pad(s.good, 3)} easy ${pad(s.easy, 3)} │`,
107
- ' │ │',
108
- ' │ press esc to exit │',
109
- ' │ │',
110
- ' ╰───────────────────────────────────────╯',
111
- '',
112
- ];
113
- }
114
-
115
- /** Drive animations. Called by the host once per repaint. */
116
- tick(): void {
117
- if (this.revealed && this.revealAnim.frame < this.revealAnim.durationFrames) {
118
- this.revealAnim.frame++;
119
- }
120
- if (this.shake !== null && this.shake.frame < this.shake.durationFrames) {
121
- this.shake.frame++;
122
- if (this.shake.frame >= this.shake.durationFrames) this.shake = null;
123
- }
124
- if (this.confetti !== null && this.confetti.frame < this.confetti.durationFrames) {
125
- this.confetti.frame++;
126
- if (this.confetti.frame >= this.confetti.durationFrames) this.confetti = null;
127
- }
128
- }
129
-
130
- /** Resize the rendering area (after a terminal resize). */
131
- resize(cellsWidth: number, cellsHeight: number): void {
132
- this.ctx.cellsWidth = cellsWidth;
133
- this.ctx.cellsHeight = cellsHeight;
134
- this.ctx.imageCache.clear();
135
- if (this.current !== undefined) {
136
- // Re-render in the background. Until the new rows arrive, the existing
137
- // rows still display — no flicker, no jump.
138
- void this.setCard(this.current);
139
- }
140
- }
141
-
142
- override onKey(event: KeyEvent): boolean {
143
- if (event.name === 'space' || event.name === 'enter') {
144
- this.revealed = true;
145
- this.revealAnim = { durationFrames: 8, frame: 0 };
146
- return true;
147
- }
148
- if (event.name === 'backspace') {
149
- this.typed = this.typed.slice(0, -1);
150
- return true;
151
- }
152
- if (this.revealed) {
153
- switch (event.name) {
154
- case '1':
155
- this.grade(0);
156
- return true;
157
- case '2':
158
- this.grade(1);
159
- return true;
160
- case '3':
161
- this.grade(2);
162
- return true;
163
- case '4':
164
- this.grade(3);
165
- return true;
166
- }
167
- }
168
- if (event.name === 'n') {
169
- this.options.onSkip();
170
- return true;
171
- }
172
- if (event.ch !== undefined && event.ch >= ' ' && event.ch !== 'n' && (event.ch < '0' || event.ch > '9')) {
173
- this.typed += event.ch;
174
- return true;
175
- }
176
- return false;
177
- }
178
-
179
- private grade(g: Grade): void {
180
- this.options.onGraded(g, this.typed);
181
- if (g === 0) {
182
- this.shake = { durationFrames: 10, frame: 0 };
183
- } else if (g === 3) {
184
- this.confetti = {
185
- frame: 0,
186
- durationFrames: 24,
187
- seed: Math.floor(Math.random() * 1e6),
188
- width: this.options.cellsWidth,
189
- palette: ['\x1b[38;5;213m', '\x1b[38;5;215m', '\x1b[38;5;220m', '\x1b[38;5;156m', '\x1b[38;5;123m'],
190
- };
191
- }
192
- this.typed = '';
193
- }
194
-
195
- render(): string[] {
196
- const out: string[] = [];
197
-
198
- // Header line: prompt + type input
199
- const lvl = detectCapabilities().colorLevel;
200
- const promptText = this.revealed
201
- ? paint('#fbbf24', 'Type your answer, then press 1-4 to grade', lvl)
202
- : paint('#94a3b8', 'Press Space to reveal', lvl);
203
- const typed = this.typed.length > 0 ? paint('#e2e8f0', ` > ${this.typed}▏`, lvl) : '';
204
- out.push(centerLine(`${promptText}${typed}`, this.options.cellsWidth));
205
-
206
- // Spacer
207
- out.push('');
208
-
209
- // Card front
210
- const frontRows = this.front.text;
211
- const centeredFront = frontRows.map((row) => centerLine(row, this.options.cellsWidth));
212
- out.push(...this.applyEffects(centeredFront, 'front'));
213
-
214
- // Spacer
215
- out.push('');
216
-
217
- // Divider
218
- if (this.revealed) {
219
- out.push(centerLine(paint('#475569', '─'.repeat(Math.min(60, this.options.cellsWidth - 4)), lvl), this.options.cellsWidth));
220
- out.push('');
221
-
222
- const backRows = this.back.text;
223
- const typedBack = this.revealed ? typeOnRows(this.revealAnim, backRows) : [];
224
- const centeredBack = typedBack.map((row) => centerLine(row, this.options.cellsWidth));
225
- out.push(...this.applyEffects(centeredBack, 'back'));
226
-
227
- // Hint, if any
228
- if (this.current?.card.hint !== undefined && this.revealed) {
229
- out.push('');
230
- out.push(centerLine(paint('#64748b', `hint: ${this.current.card.hint}`, lvl), this.options.cellsWidth));
231
- }
232
- }
233
-
234
- // Image rows (always rendered below the text).
235
- const imageRows = this.revealed
236
- ? [...this.front.imageRows, ...this.back.imageRows]
237
- : this.front.imageRows;
238
- for (const row of imageRows) {
239
- out.push(centerLine(row, this.options.cellsWidth));
240
- }
241
-
242
- // Confetti overlay
243
- if (this.confetti !== null) {
244
- const overlay = confettiRows(this.confetti, Math.min(8, this.options.cellsHeight));
245
- for (let i = 0; i < overlay.length; i++) {
246
- out[out.length - overlay.length + i] = overlay[i] ?? '';
247
- }
248
- }
249
-
250
- // Pad / truncate to fit the cell height so the screen layout is stable.
251
- while (out.length < this.options.cellsHeight) out.push('');
252
- if (out.length > this.options.cellsHeight) out.length = this.options.cellsHeight;
253
-
254
- return out;
255
- }
256
-
257
- private applyEffects(rows: string[], seedKey: string): string[] {
258
- let out = rows;
259
- if (this.shake !== null) {
260
- out = shakeRows(this.shake, out, hashString(`${seedKey}:${this.current?.card.front ?? ''}`));
261
- }
262
- return out;
263
- }
264
- }
265
-
266
- function centerLine(text: string, width: number): string {
267
- const visible = visibleLength(text);
268
- if (visible >= width) return text;
269
- const pad = Math.floor((width - visible) / 2);
270
- return ' '.repeat(pad) + text;
271
- }
272
-
273
- function hashString(s: string): number {
274
- let h = 0;
275
- for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
276
- return h;
277
- }
278
-
279
- function pad(n: number, width: number): string {
280
- return String(n).padStart(width, ' ');
281
- }