saterminal 0.1.0

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,618 @@
1
+ import { buildSummaryRows } from "../state.ts";
2
+ import { focusSummary } from "../focus.ts";
3
+ import { htmlToText, wrapText } from "../text.ts";
4
+ import type { TextSegment } from "../text.ts";
5
+ import { focusGrid, normalizeFocusGridPosition, type FocusGridColumn, type FocusGridRow } from "./focus-grid.ts";
6
+ import { historyRows } from "./history.ts";
7
+ import {
8
+ practiceQuestionApiUrl,
9
+ practiceQuestionUrl,
10
+ questionNeedsExternalDisplay,
11
+ questionRows,
12
+ } from "./question.ts";
13
+ import { elapsedQuestionSeconds, formatElapsed, timerStatus } from "./timer.ts";
14
+ import { terminalSize, tk, type TkDocument } from "./kit.ts";
15
+ import { PANE_BODY_Y, PANE_HEADER_Y, paneLayout, paneViewportHeight } from "./layout.ts";
16
+ import { detailPaneRows, practiceAnswerPaneRows, reviewPaneRows, type PaneTextRow } from "./pane-content.ts";
17
+ import type { AppState, PaneLayout } from "./types.ts";
18
+ import type { Attempt, Outcome, QuestionMeta, SummaryRow } from "../types.ts";
19
+ import { clampScroll, maxScroll, type PaneViewport } from "./viewport.ts";
20
+
21
+ type TextAttr = Record<string, unknown>;
22
+
23
+ export function createDocument(): TkDocument {
24
+ return new tk.Document({
25
+ eventSource: tk.terminal,
26
+ outputDst: tk.terminal,
27
+ backgroundAttr: {},
28
+ });
29
+ }
30
+
31
+ export function render(doc: TkDocument, state: AppState): void {
32
+ doc.clear();
33
+
34
+ if (state.view === "practice" && state.timerPaused) {
35
+ renderPaused(doc);
36
+ footer(doc, state);
37
+ doc.draw();
38
+ return;
39
+ }
40
+
41
+ header(doc, state);
42
+
43
+ if (state.view === "loading") {
44
+ text(doc, 0, 3, "Loading...");
45
+ } else if (state.view === "focus") {
46
+ renderFocus(doc, state);
47
+ } else if (state.view === "practice") {
48
+ renderPractice(doc, state);
49
+ } else if (state.view === "review") {
50
+ renderReview(doc, state);
51
+ } else if (state.view === "history") {
52
+ renderHistory(doc, state);
53
+ } else if (state.view === "summary") {
54
+ renderSummary(doc, state);
55
+ } else if (state.view === "detail") {
56
+ renderDetail(doc, state);
57
+ } else if (state.view === "error") {
58
+ renderError(doc, state);
59
+ }
60
+
61
+ footer(doc, state);
62
+ doc.draw();
63
+ }
64
+
65
+ function renderPaused(doc: TkDocument): void {
66
+ const { width, height } = terminalSize();
67
+ const label = "PAUSED";
68
+ text(doc, Math.max(0, Math.floor((width - label.length) / 2)), Math.max(0, Math.floor(height / 2)), label, { bold: true });
69
+ }
70
+
71
+ function header(doc: TkDocument, state: AppState): void {
72
+ const { width } = terminalSize();
73
+ const answered = state.attempts.size;
74
+ const timer = state.timerHidden ? "" : ` time ${formatElapsed(elapsedQuestionSeconds(state))}${timerStatus(state)}`;
75
+ text(doc, 0, 0, "sat", { bold: true });
76
+ text(doc, 7, 0, `reading/writing answered ${answered}${timer}`, { color: "gray" }, width - 7);
77
+ text(doc, 0, 1, "-".repeat(width), { color: "gray" }, width);
78
+ }
79
+
80
+ function footer(doc: TkDocument, state: AppState): void {
81
+ const { width, height } = terminalSize();
82
+ const controls = state.view === "practice"
83
+ ? practiceControls(state)
84
+ : state.view === "focus"
85
+ ? "up/down or j/k row | tab/shift-tab group | space toggle | enter start | q quit"
86
+ : state.view === "review"
87
+ ? "tab pane | up/down/j/k scroll | pg/[ ] page | g/G edge | enter next | f focus | h history | q quit"
88
+ : state.view === "history"
89
+ ? "up/down or j/k move | enter open | f focus | p practice | s summary | q quit"
90
+ : state.view === "detail"
91
+ ? "tab pane | up/down/j/k scroll | pg/[ ] page | g/G edge | esc history | p practice | q quit"
92
+ : state.view === "error"
93
+ ? "r retry | q quit"
94
+ : "p practice | h history | q quit";
95
+
96
+ text(doc, 0, Math.max(0, height - 2), "-".repeat(width), { color: "gray" }, width);
97
+ text(doc, 0, Math.max(0, height - 1), controls, { color: "gray" }, width);
98
+ }
99
+
100
+ function renderFocus(doc: TkDocument, state: AppState): void {
101
+ const { width } = terminalSize();
102
+ const columns = focusGrid(state.focus);
103
+ const position = normalizeFocusGridPosition(columns, { column: state.focusColumn, row: state.focusRow });
104
+ state.focusColumn = position.column;
105
+ state.focusRow = position.row;
106
+
107
+ const summary = focusSummary(state.focus);
108
+ text(doc, 0, 3, "study focus", { bold: true });
109
+ text(doc, Math.max(0, width - summary.length - 1), 3, summary, { color: "cyan" });
110
+ text(doc, 0, 4, "Space toggles the selected row. Enter starts practice.", { color: "gray" }, width);
111
+
112
+ renderDifficultyColumn(doc, columns[0], position.column === 0 ? position.row : -1);
113
+ renderDomainColumns(doc, columns.slice(1), position.column - 1, position.row);
114
+ }
115
+
116
+ function renderDifficultyColumn(doc: TkDocument, column: FocusGridColumn, selectedRow: number): void {
117
+ text(doc, 0, 6, column.title, { color: "cyan", bold: true });
118
+
119
+ for (const [index, row] of column.rows.entries()) {
120
+ const output = focusOptionText(row, index === selectedRow);
121
+ writeFocusOption(doc, 0, 7 + index, output, row, index === selectedRow, 32);
122
+ }
123
+ }
124
+
125
+ function renderDomainColumns(doc: TkDocument, columns: FocusGridColumn[], selectedColumn: number, selectedRow: number): void {
126
+ const { width } = terminalSize();
127
+ const startY = 11;
128
+ const gap = 4;
129
+ const perRow = width >= 112 ? 4 : width >= 72 ? 2 : 1;
130
+ const columnWidth = Math.max(24, Math.floor((width - gap * (perRow - 1)) / perRow));
131
+
132
+ for (const [index, column] of columns.entries()) {
133
+ const gridX = index % perRow;
134
+ const gridY = Math.floor(index / perRow);
135
+ const x = gridX * (columnWidth + gap);
136
+ const y = startY + gridY * 7;
137
+ const columnSelected = index === selectedColumn;
138
+
139
+ text(doc, x, y, truncate(column.title, columnWidth), { color: "cyan", bold: columnSelected });
140
+ for (const [rowIndex, row] of column.rows.entries()) {
141
+ const focused = columnSelected && rowIndex === selectedRow;
142
+ writeFocusOption(doc, x, y + rowIndex + 1, focusOptionText(row, focused), row, focused, columnWidth);
143
+ }
144
+ }
145
+ }
146
+
147
+ function focusOptionText(row: FocusGridRow, focused: boolean): string {
148
+ const marker = focused ? ">" : " ";
149
+ const checked = row.partial ? "◐" : row.checked ? "●" : "○";
150
+ const indent = row.depth > 0 ? " " : "";
151
+ return `${marker} ${indent}${checked} ${row.label}`;
152
+ }
153
+
154
+ function writeFocusOption(
155
+ doc: TkDocument,
156
+ x: number,
157
+ y: number,
158
+ value: string,
159
+ row: FocusGridRow,
160
+ focused: boolean,
161
+ width = terminalSize().width - x,
162
+ ): void {
163
+ const attr = focused ? { color: "yellow", bold: true } : row.checked || row.partial ? { color: "green" } : { color: "gray" };
164
+ text(doc, x, y, value, attr, width);
165
+ }
166
+
167
+ function renderPractice(doc: TkDocument, state: AppState): void {
168
+ if (!state.question) {
169
+ return;
170
+ }
171
+
172
+ const panes = paneLayout();
173
+ if (questionNeedsExternalDisplay(state.question.detail)) {
174
+ renderUnsupportedQuestion(doc, state, panes);
175
+ return;
176
+ }
177
+
178
+ const questionContent = questionRows(state.question.detail, panes.leftWidth);
179
+ const answerContent = practiceAnswerPaneRows(state.question, state.selected, panes.rightWidth).rows;
180
+ const questionViewport = clampedViewport(state.questionScroll, questionContent.length);
181
+ const answerViewport = clampedViewport(state.answerScroll, answerContent.length);
182
+ state.questionScroll = questionViewport.scroll;
183
+ state.answerScroll = answerViewport.scroll;
184
+
185
+ renderPaneHeaders(doc, panes, state, questionViewport, answerViewport, "answers");
186
+ renderQuestionRows(doc, questionContent, panes.leftX, panes.leftWidth, questionViewport, state.activePane === "question");
187
+ renderPaneRows(doc, answerContent, panes.rightX, panes.rightWidth, answerViewport, state.activePane === "answers");
188
+ }
189
+
190
+ function renderReview(doc: TkDocument, state: AppState): void {
191
+ if (!state.question) {
192
+ return;
193
+ }
194
+
195
+ const panes = paneLayout();
196
+ const questionContent = questionRows(state.question.detail, panes.leftWidth);
197
+ const reviewContent = reviewPaneRows(state.question, {
198
+ lastAnswer: state.lastAnswer,
199
+ lastCorrect: state.lastCorrect,
200
+ elapsed: formatElapsed(elapsedQuestionSeconds(state)),
201
+ }, panes.rightWidth);
202
+ const questionViewport = clampedViewport(state.questionScroll, questionContent.length);
203
+ const answerViewport = clampedViewport(state.answerScroll, reviewContent.length);
204
+ state.questionScroll = questionViewport.scroll;
205
+ state.answerScroll = answerViewport.scroll;
206
+
207
+ renderPaneHeaders(doc, panes, state, questionViewport, answerViewport, "review");
208
+ renderQuestionRows(doc, questionContent, panes.leftX, panes.leftWidth, questionViewport, state.activePane === "question");
209
+ renderPaneRows(doc, reviewContent, panes.rightX, panes.rightWidth, answerViewport, state.activePane === "answers");
210
+ }
211
+
212
+ function renderHistory(doc: TkDocument, state: AppState): void {
213
+ const attempts = historyRows(state);
214
+ const { width, height } = terminalSize();
215
+ let y = 3;
216
+ text(doc, 0, y++, "answered questions", { color: "cyan", bold: true });
217
+ y++;
218
+
219
+ if (attempts.length === 0) {
220
+ text(doc, 0, y, "No attempts yet.", { color: "gray" });
221
+ return;
222
+ }
223
+
224
+ text(doc, 0, y++, " question status updated", { color: "gray" }, width);
225
+
226
+ const visibleRows = Math.max(1, height - 8);
227
+ const start = Math.max(0, Math.min(state.historyIndex - visibleRows + 1, attempts.length - visibleRows));
228
+ for (const [offset, attempt] of attempts.slice(start, start + visibleRows).entries()) {
229
+ const index = start + offset;
230
+ renderHistoryRow(doc, attempt, index === state.historyIndex, y++, width);
231
+ }
232
+ }
233
+
234
+ function renderSummary(doc: TkDocument, state: AppState): void {
235
+ const { width } = terminalSize();
236
+ let y = 3;
237
+ text(doc, 0, y++, "stats summary", { color: "cyan", bold: true });
238
+ text(doc, 0, y++, "practice progress from recorded attempts", { color: "gray" }, width);
239
+ y++;
240
+ for (const row of buildSummaryRows(state.attempts)) {
241
+ renderSummaryRow(doc, row, y++, width);
242
+ }
243
+ }
244
+
245
+ function renderDetail(doc: TkDocument, state: AppState): void {
246
+ const panes = paneLayout();
247
+ if (!state.detailQuestion) {
248
+ text(doc, 0, 3, "Could not fetch details for this question.");
249
+ return;
250
+ }
251
+
252
+ const { meta, detail } = state.detailQuestion;
253
+ const attempt = state.attempts.get(meta.questionId);
254
+ if (questionNeedsExternalDisplay(detail)) {
255
+ renderUnsupportedQuestion(doc, { ...state, question: state.detailQuestion }, panes);
256
+ return;
257
+ }
258
+
259
+ const questionContent = questionRows(detail, panes.leftWidth);
260
+ const detailContent = detailPaneRows(state.detailQuestion, attempt, panes.rightWidth);
261
+ const questionViewport = clampedViewport(state.questionScroll, questionContent.length);
262
+ const answerViewport = clampedViewport(state.answerScroll, detailContent.length);
263
+ state.questionScroll = questionViewport.scroll;
264
+ state.answerScroll = answerViewport.scroll;
265
+
266
+ renderPaneHeaders(doc, panes, state, questionViewport, answerViewport, "answers");
267
+ renderQuestionRows(doc, questionContent, panes.leftX, panes.leftWidth, questionViewport, state.activePane === "question");
268
+ renderPaneRows(doc, detailContent, panes.rightX, panes.rightWidth, answerViewport, state.activePane === "answers");
269
+ }
270
+
271
+ function renderError(doc: TkDocument, state: AppState): void {
272
+ text(doc, 0, 3, "Something went wrong.", { bold: true, color: "red" });
273
+ printWrappedAt(doc, state.error ?? "Unknown error.", 0, 5, terminalSize().width - 1, terminalSize().height - 4);
274
+ }
275
+
276
+ function renderUnsupportedQuestion(doc: TkDocument, state: AppState, panes: PaneLayout): void {
277
+ if (!state.question) {
278
+ return;
279
+ }
280
+
281
+ const { meta, detail } = state.question;
282
+ let y = 3;
283
+ text(doc, panes.leftX, y++, "This question contains a table.", { color: "yellow", bold: true }, panes.leftWidth);
284
+ y++;
285
+ y = printWrappedAt(
286
+ doc,
287
+ "The terminal renderer cannot display this table accurately enough to answer the question here.",
288
+ panes.leftX,
289
+ y,
290
+ panes.leftWidth,
291
+ terminalSize().height - 4,
292
+ false,
293
+ { color: "gray" },
294
+ );
295
+ y++;
296
+ y = printWrappedAt(
297
+ doc,
298
+ "Open it in PracticeSAT, search this question ID, then skip it in this app.",
299
+ panes.leftX,
300
+ y,
301
+ panes.leftWidth,
302
+ terminalSize().height - 4,
303
+ false,
304
+ { color: "gray" },
305
+ );
306
+ y++;
307
+ y = printWrappedAt(doc, `Question ID: ${meta.questionId}`, panes.leftX, y, panes.leftWidth, terminalSize().height - 4, false, {
308
+ color: "yellow",
309
+ });
310
+ y++;
311
+ y = printWrappedAt(doc, practiceQuestionUrl(state.question), panes.leftX, y, panes.leftWidth, terminalSize().height - 4, false, {
312
+ color: "cyan",
313
+ });
314
+ y++;
315
+ printWrappedAt(
316
+ doc,
317
+ `API fallback: ${practiceQuestionApiUrl(state.question)}`,
318
+ panes.leftX,
319
+ y,
320
+ panes.leftWidth,
321
+ terminalSize().height - 4,
322
+ false,
323
+ { color: "cyan" },
324
+ );
325
+
326
+ let rightY = 3;
327
+ rightY = renderQuestionMetadata(doc, meta, state.attempts.get(meta.questionId), panes.rightX, rightY, panes.rightWidth);
328
+ rightY++;
329
+ text(doc, panes.rightX, rightY++, "o open externally", { color: "green" }, panes.rightWidth);
330
+ text(doc, panes.rightX, rightY++, "n/x/enter skip", { color: "yellow" }, panes.rightWidth);
331
+ rightY++;
332
+ printWrappedAt(doc, htmlToText(detail.stem), panes.rightX, rightY, panes.rightWidth, terminalSize().height - 4, true);
333
+ }
334
+
335
+ function renderPaneHeaders(
336
+ doc: TkDocument,
337
+ panes: PaneLayout,
338
+ state: AppState,
339
+ questionViewport: PaneViewport,
340
+ answerViewport: PaneViewport,
341
+ rightLabel: string,
342
+ ): void {
343
+ renderPaneHeader(doc, panes.leftX, panes.leftWidth, "question", state.activePane === "question", questionViewport);
344
+ renderPaneHeader(doc, panes.rightX, panes.rightWidth, rightLabel, state.activePane === "answers", answerViewport);
345
+ }
346
+
347
+ function renderPaneHeader(doc: TkDocument, x: number, width: number, label: string, active: boolean, viewport: PaneViewport): void {
348
+ const attr = active ? { color: "cyan", underline: true } : { color: "gray" };
349
+ text(doc, x, PANE_HEADER_Y, label, attr, width);
350
+
351
+ if (maxScroll(viewport) === 0) {
352
+ return;
353
+ }
354
+
355
+ const end = Math.min(viewport.contentRows, viewport.scroll + viewport.height);
356
+ const position = `${viewport.scroll + 1}-${end}/${viewport.contentRows}`;
357
+ text(doc, Math.max(x, x + width - position.length), PANE_HEADER_Y, position, active ? { color: "cyan" } : { color: "gray" }, width);
358
+ }
359
+
360
+ function renderQuestionRows(
361
+ doc: TkDocument,
362
+ rows: ReturnType<typeof questionRows>,
363
+ x: number,
364
+ width: number,
365
+ viewport: PaneViewport,
366
+ active: boolean,
367
+ ): void {
368
+ for (const [offset, row] of rows.slice(viewport.scroll, viewport.scroll + viewport.height).entries()) {
369
+ renderStyledLine(doc, x, PANE_BODY_Y + offset, width, row.segments, row.bold);
370
+ }
371
+
372
+ renderScrollGutter(doc, x, width, viewport, active);
373
+ }
374
+
375
+ function renderPaneRows(doc: TkDocument, rows: PaneTextRow[], x: number, width: number, viewport: PaneViewport, active: boolean): void {
376
+ for (const [offset, row] of rows.slice(viewport.scroll, viewport.scroll + viewport.height).entries()) {
377
+ text(doc, x, PANE_BODY_Y + offset, row.text, paneRowAttr(row), width);
378
+ }
379
+
380
+ renderScrollGutter(doc, x, width, viewport, active);
381
+ }
382
+
383
+ function renderScrollGutter(doc: TkDocument, x: number, width: number, viewport: PaneViewport, active: boolean): void {
384
+ const scrollX = Math.min(terminalSize().width - 1, x + width);
385
+ const attr = active ? { color: "cyan" } : { color: "gray" };
386
+ if (viewport.scroll > 0) {
387
+ text(doc, scrollX, PANE_BODY_Y, "^", attr, 1);
388
+ }
389
+ if (viewport.scroll < maxScroll(viewport)) {
390
+ text(doc, scrollX, PANE_BODY_Y + viewport.height - 1, "v", attr, 1);
391
+ }
392
+ }
393
+
394
+ function clampedViewport(scroll: number, contentRows: number): PaneViewport {
395
+ const viewport = { scroll, height: paneViewportHeight(), contentRows };
396
+ return { ...viewport, scroll: clampScroll(viewport) };
397
+ }
398
+
399
+ function paneRowAttr(row: PaneTextRow): TextAttr {
400
+ const attr: TextAttr = row.bold ? { bold: true } : {};
401
+
402
+ if (row.kind === "muted") {
403
+ return { ...attr, color: "gray" };
404
+ }
405
+ if (row.kind === "heading" || row.kind === "info") {
406
+ return { ...attr, color: "cyan", ...(row.kind === "heading" ? { bold: true } : {}) };
407
+ }
408
+ if (row.kind === "success") {
409
+ return { ...attr, color: "green" };
410
+ }
411
+ if (row.kind === "danger") {
412
+ return { ...attr, color: "red" };
413
+ }
414
+ if (row.kind === "warning" || row.kind === "selected") {
415
+ return { ...attr, color: "yellow", ...(row.kind === "selected" ? { bold: true } : {}) };
416
+ }
417
+
418
+ return attr;
419
+ }
420
+
421
+ function renderStyledLine(doc: TkDocument, x: number, y: number, width: number, segments: TextSegment[], forceBold = false): void {
422
+ let col = 0;
423
+ for (const segment of segments) {
424
+ if (col >= width) {
425
+ break;
426
+ }
427
+
428
+ const output = segment.text.slice(0, width - col);
429
+ if (!output) {
430
+ continue;
431
+ }
432
+
433
+ text(doc, x + col, y, output, {
434
+ bold: forceBold || segment.style.bold,
435
+ underline: segment.style.underline,
436
+ italic: segment.style.italic,
437
+ });
438
+ col += output.length;
439
+ }
440
+ }
441
+
442
+ function printWrappedAt(
443
+ doc: TkDocument,
444
+ value: string | undefined,
445
+ x: number,
446
+ y: number,
447
+ width: number,
448
+ maxY: number,
449
+ bold = false,
450
+ attr: TextAttr = {},
451
+ ): number {
452
+ for (const row of wrapText(value ?? "", width)) {
453
+ if (y > maxY) {
454
+ text(doc, x, y, "...", { color: "gray" }, width);
455
+ return y + 1;
456
+ }
457
+ text(doc, x, y++, row, { ...attr, ...(bold ? { bold: true } : {}) }, width);
458
+ }
459
+ return y;
460
+ }
461
+
462
+ function renderHistoryRow(doc: TkDocument, attempt: Attempt, selected: boolean, y: number, width: number): void {
463
+ const strong = selected ? { bold: true } : {};
464
+ text(doc, 0, y, selected ? ">" : " ", selected ? { color: "yellow", bold: true } : { color: "gray" }, 1);
465
+ text(doc, 2, y, attempt.question_id, { color: selected ? "yellow" : "cyan", ...strong }, 10);
466
+ text(doc, 13, y, attempt.outcome, { ...outcomeAttr(attempt.outcome), ...strong }, 9);
467
+ text(doc, 24, y, attempt.updated_at, { color: selected ? "yellow" : "gray", ...strong }, width - 24);
468
+ }
469
+
470
+ function renderSummaryRow(doc: TkDocument, row: SummaryRow, y: number, width: number): void {
471
+ const value = summaryValue(row);
472
+ const attr = summaryAttr(row);
473
+
474
+ text(doc, 0, y, summaryLabel(row.metric), { color: "gray" }, 16);
475
+ text(doc, 17, y, value, attr, 10);
476
+
477
+ const barWidth = width - 30;
478
+ if (row.metric === "accuracy" && barWidth >= 10) {
479
+ text(doc, 29, y, accuracyBar(row.value, Math.min(24, barWidth)), attr, barWidth);
480
+ }
481
+ }
482
+
483
+ function renderQuestionMetadata(
484
+ doc: TkDocument,
485
+ meta: QuestionMeta,
486
+ attempt: Attempt | undefined,
487
+ x: number,
488
+ y: number,
489
+ width: number,
490
+ ): number {
491
+ if (attempt) {
492
+ text(doc, x, y++, attempt.outcome.toUpperCase(), { ...outcomeAttr(attempt.outcome), bold: true }, width);
493
+ }
494
+
495
+ text(doc, x, y++, formatDomain(meta), { color: "cyan" }, width);
496
+ text(doc, x, y++, formatSkill(meta), { color: "cyan" }, width);
497
+ text(doc, x, y++, `difficulty ${meta.difficulty} | ${meta.questionId}`, difficultyAttr(meta.difficulty), width);
498
+
499
+ return y;
500
+ }
501
+
502
+ function formatDomain(meta: QuestionMeta): string {
503
+ return meta.primary_class_cd_desc ? `${meta.primary_class_cd} ${meta.primary_class_cd_desc}` : meta.primary_class_cd;
504
+ }
505
+
506
+ function formatSkill(meta: QuestionMeta): string {
507
+ return meta.skill_desc ? `${meta.skill_cd} ${meta.skill_desc}` : meta.skill_cd;
508
+ }
509
+
510
+ function outcomeAttr(outcome: Outcome): TextAttr {
511
+ if (outcome === "correct") {
512
+ return { color: "green" };
513
+ }
514
+ if (outcome === "incorrect") {
515
+ return { color: "red" };
516
+ }
517
+ return { color: "yellow" };
518
+ }
519
+
520
+ function difficultyAttr(difficulty: string): TextAttr {
521
+ if (difficulty === "E") {
522
+ return { color: "green" };
523
+ }
524
+ if (difficulty === "H") {
525
+ return { color: "red" };
526
+ }
527
+ return { color: "yellow" };
528
+ }
529
+
530
+ function summaryLabel(metric: string): string {
531
+ if (metric === "avg_seconds") {
532
+ return "avg seconds";
533
+ }
534
+ return metric;
535
+ }
536
+
537
+ function summaryValue(row: SummaryRow): string {
538
+ if (row.metric === "accuracy") {
539
+ return `${Math.round(readRatio(row.value) * 100)}%`;
540
+ }
541
+ if (row.metric === "avg_seconds") {
542
+ return `${row.value}s`;
543
+ }
544
+ return row.value;
545
+ }
546
+
547
+ function summaryAttr(row: SummaryRow): TextAttr {
548
+ if (row.metric === "answered") {
549
+ return { color: "cyan" };
550
+ }
551
+ if (row.metric === "correct") {
552
+ return { color: "green", bold: true };
553
+ }
554
+ if (row.metric === "incorrect") {
555
+ return { color: "red", bold: true };
556
+ }
557
+ if (row.metric === "corrected") {
558
+ return { color: "yellow", bold: true };
559
+ }
560
+ if (row.metric === "accuracy") {
561
+ const ratio = readRatio(row.value);
562
+ return ratio >= 0.8 ? { color: "green", bold: true } : ratio >= 0.6 ? { color: "yellow", bold: true } : { color: "red", bold: true };
563
+ }
564
+ return { color: "cyan" };
565
+ }
566
+
567
+ function accuracyBar(value: string, width: number): string {
568
+ const barWidth = Math.max(8, width - 2);
569
+ const ratio = readRatio(value);
570
+ const filled = Math.round(ratio * barWidth);
571
+ return `[${"#".repeat(filled)}${"-".repeat(barWidth - filled)}]`;
572
+ }
573
+
574
+ function readRatio(value: string): number {
575
+ const ratio = Number(value);
576
+ if (!Number.isFinite(ratio)) {
577
+ return 0;
578
+ }
579
+ return Math.min(1, Math.max(0, ratio));
580
+ }
581
+
582
+ function text(doc: TkDocument, x: number, y: number, value: string, attr: TextAttr = {}, width = terminalSize().width - x): void {
583
+ if (y < 0 || y >= terminalSize().height || width <= 0) {
584
+ return;
585
+ }
586
+
587
+ const content = truncate(value, width);
588
+ if (!content) {
589
+ return;
590
+ }
591
+
592
+ new tk.Text({
593
+ parent: doc,
594
+ x,
595
+ y,
596
+ content,
597
+ attr,
598
+ noDraw: true,
599
+ });
600
+ }
601
+
602
+ function truncate(value: string, width: number): string {
603
+ if (value.length <= width) {
604
+ return value;
605
+ }
606
+ if (width <= 1) {
607
+ return value.slice(0, width);
608
+ }
609
+ return `${value.slice(0, width - 1)}…`;
610
+ }
611
+
612
+ function practiceControls(state: AppState): string {
613
+ if (state.question && questionNeedsExternalDisplay(state.question.detail)) {
614
+ return "space pause/resume | t timer | o open externally | n/x/enter skip | f focus | h history | s summary | q quit";
615
+ }
616
+
617
+ return "space pause/resume | t timer | tab pane | up/down/j/k move/scroll | pg/[ ] page | g/G edge | enter submit | q quit";
618
+ }