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.
- package/.envrc +1 -0
- package/AGENTS.md +23 -0
- package/README.md +4 -0
- package/bun.lock +100 -0
- package/flake.lock +59 -0
- package/flake.nix +21 -0
- package/package.json +26 -0
- package/src/api.ts +78 -0
- package/src/focus.ts +231 -0
- package/src/main.ts +4 -0
- package/src/state.ts +203 -0
- package/src/text.ts +256 -0
- package/src/tui/app.ts +77 -0
- package/src/tui/focus-grid.ts +113 -0
- package/src/tui/history.ts +6 -0
- package/src/tui/input.ts +411 -0
- package/src/tui/kit.ts +29 -0
- package/src/tui/layout.ts +25 -0
- package/src/tui/pane-content.ts +150 -0
- package/src/tui/question.ts +104 -0
- package/src/tui/render.ts +618 -0
- package/src/tui/terminal.ts +110 -0
- package/src/tui/timer.ts +43 -0
- package/src/tui/types.ts +48 -0
- package/src/tui/viewport.ts +60 -0
- package/src/tui.ts +1 -0
- package/src/types/terminal-kit.d.ts +118 -0
- package/src/types.ts +52 -0
- package/test/api.test.ts +80 -0
- package/test/focus.test.ts +55 -0
- package/test/state.test.ts +120 -0
- package/test/text.test.ts +72 -0
- package/test/viewport.test.ts +39 -0
- package/tsconfig.json +13 -0
package/src/tui/input.ts
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
import { fetchPracticeQuestion, findQuestionByShortId } from "../api.ts";
|
|
2
|
+
import { recordAttempt, saveAttempts, saveFocus, saveSummary } from "../state.ts";
|
|
3
|
+
import { focusGrid, moveFocusGridPosition, normalizeFocusGridPosition, toggleFocusGridRow } from "./focus-grid.ts";
|
|
4
|
+
import {
|
|
5
|
+
answerKeys,
|
|
6
|
+
openExternalQuestion,
|
|
7
|
+
questionNeedsExternalDisplay,
|
|
8
|
+
questionRows,
|
|
9
|
+
} from "./question.ts";
|
|
10
|
+
import { historyRows } from "./history.ts";
|
|
11
|
+
import { elapsedQuestionSeconds, formatElapsed, pauseTimer, resumeTimer, toggleTimer } from "./timer.ts";
|
|
12
|
+
import { paneLayout, paneViewportHeight } from "./layout.ts";
|
|
13
|
+
import { detailPaneRows, practiceAnswerPaneRows, reviewPaneRows } from "./pane-content.ts";
|
|
14
|
+
import { alternatePane, ensureRangeVisible, scrollBy, scrollPage, scrollToEdge, type PaneViewport } from "./viewport.ts";
|
|
15
|
+
import type { AppState, KeyData } from "./types.ts";
|
|
16
|
+
import type { PracticeQuestion } from "../types.ts";
|
|
17
|
+
|
|
18
|
+
export function isPauseKey(name: string, data?: KeyData): boolean {
|
|
19
|
+
if (name === " " || name.toUpperCase() === "SPACE") {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (data?.isCharacter && data.codepoint === 32) {
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (data?.code === " ") {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (Buffer.isBuffer(data?.code) && data.code.toString("utf8") === " ") {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function handleKey(state: AppState, name: string, data?: KeyData): Promise<void> {
|
|
39
|
+
if (state.view === "loading") {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (state.view === "focus") {
|
|
44
|
+
await handleFocusKey(state, name, data);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (name === "f") {
|
|
49
|
+
pauseTimer(state);
|
|
50
|
+
state.view = "focus";
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (name === "h") {
|
|
55
|
+
pauseTimer(state);
|
|
56
|
+
state.view = "history";
|
|
57
|
+
state.historyIndex = 0;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (name === "s") {
|
|
62
|
+
pauseTimer(state);
|
|
63
|
+
state.view = "summary";
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (name === "t") {
|
|
68
|
+
state.timerHidden = !state.timerHidden;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (name === "p") {
|
|
73
|
+
state.view = state.question ? "practice" : "loading";
|
|
74
|
+
resetPaneScroll(state);
|
|
75
|
+
resumeTimer(state);
|
|
76
|
+
if (!state.question) {
|
|
77
|
+
await loadNextQuestion(state);
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (state.view === "error") {
|
|
83
|
+
if (name === "r") {
|
|
84
|
+
state.view = "loading";
|
|
85
|
+
await loadNextQuestion(state);
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (isScrollablePaneView(state) && handlePaneKey(state, name)) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (state.view === "practice") {
|
|
95
|
+
if (isPauseKey(name, data)) {
|
|
96
|
+
toggleTimer(state);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (state.question && questionNeedsExternalDisplay(state.question.detail)) {
|
|
101
|
+
if (name === "n" || name === "x" || name === "ENTER") {
|
|
102
|
+
skipQuestion(state);
|
|
103
|
+
await loadNextQuestion(state);
|
|
104
|
+
} else if (name === "o") {
|
|
105
|
+
openExternalQuestion(state.question);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const choices = answerKeys(state.question);
|
|
111
|
+
const directChoice = choices.findIndex((choice) => choice.toLowerCase() === name.toLowerCase());
|
|
112
|
+
if (directChoice >= 0) {
|
|
113
|
+
state.selected = directChoice;
|
|
114
|
+
ensureSelectedAnswerVisible(state);
|
|
115
|
+
} else if (name === "ENTER") {
|
|
116
|
+
const answer = choices[state.selected];
|
|
117
|
+
const correct = state.question?.detail.correct_answer.includes(answer) ?? false;
|
|
118
|
+
const elapsedSeconds = elapsedQuestionSeconds(state);
|
|
119
|
+
state.lastAnswer = answer;
|
|
120
|
+
state.lastCorrect = correct;
|
|
121
|
+
pauseTimer(state);
|
|
122
|
+
if (state.question) {
|
|
123
|
+
recordAttempt(state.attempts, state.question.meta.questionId, correct, elapsedSeconds);
|
|
124
|
+
await saveAttempts(state.attempts);
|
|
125
|
+
await saveSummary(state.attempts);
|
|
126
|
+
}
|
|
127
|
+
state.view = "review";
|
|
128
|
+
state.activePane = "answers";
|
|
129
|
+
state.answerScroll = 0;
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (state.view === "review" && (name === "ENTER" || name === "n")) {
|
|
135
|
+
await loadNextQuestion(state);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (state.view === "history") {
|
|
140
|
+
const attempts = historyRows(state);
|
|
141
|
+
if (name === "UP" || name === "k") {
|
|
142
|
+
state.historyIndex = Math.max(0, state.historyIndex - 1);
|
|
143
|
+
} else if (name === "DOWN" || name === "j") {
|
|
144
|
+
state.historyIndex = Math.min(attempts.length - 1, state.historyIndex + 1);
|
|
145
|
+
} else if (name === "ENTER" && attempts[state.historyIndex]) {
|
|
146
|
+
state.view = "loading";
|
|
147
|
+
resetPaneScroll(state);
|
|
148
|
+
state.detailQuestion = await findQuestionByShortId(attempts[state.historyIndex].question_id);
|
|
149
|
+
state.view = "detail";
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (state.view === "detail" && name === "ESCAPE") {
|
|
155
|
+
state.view = "history";
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function handleFocusKey(state: AppState, name: string, data?: KeyData): Promise<void> {
|
|
160
|
+
const columns = focusGrid(state.focus);
|
|
161
|
+
const position = normalizeFocusGridPosition(columns, { column: state.focusColumn, row: state.focusRow });
|
|
162
|
+
state.focusColumn = position.column;
|
|
163
|
+
state.focusRow = position.row;
|
|
164
|
+
|
|
165
|
+
if (name === "UP" || name === "k") {
|
|
166
|
+
const next = moveFocusGridPosition(columns, position, "up");
|
|
167
|
+
state.focusColumn = next.column;
|
|
168
|
+
state.focusRow = next.row;
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (name === "DOWN" || name === "j") {
|
|
173
|
+
const next = moveFocusGridPosition(columns, position, "down");
|
|
174
|
+
state.focusColumn = next.column;
|
|
175
|
+
state.focusRow = next.row;
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (name === "TAB") {
|
|
180
|
+
const next = moveFocusGridPosition(columns, position, "next");
|
|
181
|
+
state.focusColumn = next.column;
|
|
182
|
+
state.focusRow = next.row;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (name === "SHIFT_TAB") {
|
|
187
|
+
const next = moveFocusGridPosition(columns, position, "previous");
|
|
188
|
+
state.focusColumn = next.column;
|
|
189
|
+
state.focusRow = next.row;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (isPauseKey(name, data)) {
|
|
194
|
+
const focus = toggleFocusGridRow(state.focus, position);
|
|
195
|
+
if (focus !== state.focus) {
|
|
196
|
+
state.focus = focus;
|
|
197
|
+
state.nextQuestion = undefined;
|
|
198
|
+
state.question = undefined;
|
|
199
|
+
await saveFocus(state.focus);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (name === "ENTER") {
|
|
205
|
+
state.nextQuestion = undefined;
|
|
206
|
+
state.question = undefined;
|
|
207
|
+
await loadNextQuestion(state);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export async function loadNextQuestion(state: AppState): Promise<void> {
|
|
212
|
+
state.view = "loading";
|
|
213
|
+
state.question = await takeNextQuestion(state);
|
|
214
|
+
state.selected = 0;
|
|
215
|
+
resetPaneScroll(state);
|
|
216
|
+
state.elapsedMs = 0;
|
|
217
|
+
state.timerStartedAt = Date.now();
|
|
218
|
+
state.timerPaused = false;
|
|
219
|
+
state.lastAnswer = undefined;
|
|
220
|
+
state.lastCorrect = undefined;
|
|
221
|
+
state.view = "practice";
|
|
222
|
+
cacheNextQuestion(state);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function takeNextQuestion(state: AppState) {
|
|
226
|
+
const cached = state.nextQuestion;
|
|
227
|
+
state.nextQuestion = undefined;
|
|
228
|
+
|
|
229
|
+
if (cached) {
|
|
230
|
+
const question = await cached;
|
|
231
|
+
if (question) {
|
|
232
|
+
return question;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return fetchPracticeQuestion(questionExclusions(state), state.focus);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function cacheNextQuestion(state: AppState): void {
|
|
240
|
+
state.nextQuestion = fetchPracticeQuestion(questionExclusions(state), state.focus).catch(() => undefined);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function questionExclusions(state: AppState): string[] {
|
|
244
|
+
return [
|
|
245
|
+
...state.attempts.keys(),
|
|
246
|
+
...state.skippedIds,
|
|
247
|
+
...(state.question ? [state.question.meta.questionId] : []),
|
|
248
|
+
];
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function skipQuestion(state: AppState): void {
|
|
252
|
+
if (state.question) {
|
|
253
|
+
state.skippedIds.add(state.question.meta.questionId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function handlePaneKey(state: AppState, name: string): boolean {
|
|
258
|
+
if (name === "TAB" || name === "SHIFT_TAB") {
|
|
259
|
+
state.activePane = alternatePane(state.activePane);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (state.view === "practice" && state.activePane === "answers" && (name === "UP" || name === "k" || name === "DOWN" || name === "j")) {
|
|
264
|
+
moveSelectedAnswer(state, name === "UP" || name === "k" ? -1 : 1);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (name === "UP" || name === "k") {
|
|
269
|
+
scrollActivePaneBy(state, -1);
|
|
270
|
+
return true;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (name === "DOWN" || name === "j") {
|
|
274
|
+
scrollActivePaneBy(state, 1);
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (name === "PAGE_UP" || name === "[") {
|
|
279
|
+
scrollActivePanePage(state, -1);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (name === "PAGE_DOWN" || name === "]") {
|
|
284
|
+
scrollActivePanePage(state, 1);
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (name === "HOME" || name === "g") {
|
|
289
|
+
scrollActivePaneToEdge(state, "top");
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (name === "END" || name === "G") {
|
|
294
|
+
scrollActivePaneToEdge(state, "bottom");
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return false;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function moveSelectedAnswer(state: AppState, delta: number): void {
|
|
302
|
+
const choices = answerKeys(state.question);
|
|
303
|
+
if (choices.length === 0) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
state.selected = Math.max(0, Math.min(choices.length - 1, state.selected + delta));
|
|
308
|
+
ensureSelectedAnswerVisible(state);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function scrollActivePaneBy(state: AppState, delta: number): void {
|
|
312
|
+
setActivePaneScroll(state, scrollBy(activePaneViewport(state), delta));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function scrollActivePanePage(state: AppState, direction: 1 | -1): void {
|
|
316
|
+
setActivePaneScroll(state, scrollPage(activePaneViewport(state), direction));
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function scrollActivePaneToEdge(state: AppState, edge: "top" | "bottom"): void {
|
|
320
|
+
setActivePaneScroll(state, scrollToEdge(activePaneViewport(state), edge));
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function activePaneViewport(state: AppState): PaneViewport {
|
|
324
|
+
const panes = paneLayout();
|
|
325
|
+
const height = paneViewportHeight();
|
|
326
|
+
|
|
327
|
+
if (state.activePane === "question") {
|
|
328
|
+
const question = currentPaneQuestion(state);
|
|
329
|
+
return {
|
|
330
|
+
scroll: state.questionScroll,
|
|
331
|
+
height,
|
|
332
|
+
contentRows: question ? questionRows(question.detail, panes.leftWidth).length : 0,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
scroll: state.answerScroll,
|
|
338
|
+
height,
|
|
339
|
+
contentRows: rightPaneRows(state, panes.rightWidth).length,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function setActivePaneScroll(state: AppState, scroll: number): void {
|
|
344
|
+
if (state.activePane === "question") {
|
|
345
|
+
state.questionScroll = scroll;
|
|
346
|
+
} else {
|
|
347
|
+
state.answerScroll = scroll;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function ensureSelectedAnswerVisible(state: AppState): void {
|
|
352
|
+
if (!state.question) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const panes = paneLayout();
|
|
357
|
+
const content = practiceAnswerPaneRows(state.question, state.selected, panes.rightWidth);
|
|
358
|
+
const choice = content.choices[state.selected];
|
|
359
|
+
if (!choice) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
state.answerScroll = ensureRangeVisible(
|
|
364
|
+
{
|
|
365
|
+
scroll: state.answerScroll,
|
|
366
|
+
height: paneViewportHeight(),
|
|
367
|
+
contentRows: content.rows.length,
|
|
368
|
+
},
|
|
369
|
+
choice.start,
|
|
370
|
+
choice.end,
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function resetPaneScroll(state: AppState): void {
|
|
375
|
+
state.questionScroll = 0;
|
|
376
|
+
state.answerScroll = 0;
|
|
377
|
+
state.activePane = "answers";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function currentPaneQuestion(state: AppState): PracticeQuestion | undefined {
|
|
381
|
+
return state.view === "detail" ? state.detailQuestion : state.question;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isScrollablePaneView(state: AppState): boolean {
|
|
385
|
+
if (state.view !== "practice" && state.view !== "review" && state.view !== "detail") {
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const question = currentPaneQuestion(state);
|
|
390
|
+
return Boolean(question && !questionNeedsExternalDisplay(question.detail));
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function rightPaneRows(state: AppState, width: number) {
|
|
394
|
+
if (state.view === "practice" && state.question) {
|
|
395
|
+
return practiceAnswerPaneRows(state.question, state.selected, width).rows;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (state.view === "review" && state.question) {
|
|
399
|
+
return reviewPaneRows(state.question, {
|
|
400
|
+
lastAnswer: state.lastAnswer,
|
|
401
|
+
lastCorrect: state.lastCorrect,
|
|
402
|
+
elapsed: formatElapsed(elapsedQuestionSeconds(state)),
|
|
403
|
+
}, width);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (state.view === "detail" && state.detailQuestion) {
|
|
407
|
+
return detailPaneRows(state.detailQuestion, state.attempts.get(state.detailQuestion.meta.questionId), width);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return [];
|
|
411
|
+
}
|
package/src/tui/kit.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import terminalKit from "terminal-kit";
|
|
2
|
+
|
|
3
|
+
export const tk = terminalKit;
|
|
4
|
+
export const term = terminalKit.terminal;
|
|
5
|
+
export const gutter = 2;
|
|
6
|
+
|
|
7
|
+
export type TkDocument = InstanceType<typeof terminalKit.Document>;
|
|
8
|
+
export type TkColumnMenuMulti = InstanceType<typeof terminalKit.ColumnMenuMulti>;
|
|
9
|
+
export type TkWindow = InstanceType<typeof terminalKit.Window>;
|
|
10
|
+
|
|
11
|
+
export function terminalSize(): { width: number; height: number } {
|
|
12
|
+
const width = Number.isFinite(term.width) ? term.width : 80;
|
|
13
|
+
const height = Number.isFinite(term.height) ? term.height : 24;
|
|
14
|
+
return { width, height };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function contentBounds(): { x: number; y: number; width: number; height: number } {
|
|
18
|
+
const { width, height } = terminalSize();
|
|
19
|
+
return {
|
|
20
|
+
x: 1,
|
|
21
|
+
y: 4,
|
|
22
|
+
width: Math.max(40, width),
|
|
23
|
+
height: Math.max(10, height - 5),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function menuItem(content: string, value: string): { content: string; value: string } {
|
|
28
|
+
return { content, value };
|
|
29
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { terminalSize } from "./kit.ts";
|
|
2
|
+
import type { PaneLayout } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export const PANE_HEADER_Y = 2;
|
|
5
|
+
export const PANE_BODY_Y = 4;
|
|
6
|
+
|
|
7
|
+
export function paneLayout(): PaneLayout {
|
|
8
|
+
const { width } = terminalSize();
|
|
9
|
+
const gutter = 2;
|
|
10
|
+
const usable = Math.max(40, width - gutter);
|
|
11
|
+
const leftWidth = Math.max(20, Math.floor(usable / 2));
|
|
12
|
+
const rightX = leftWidth + gutter;
|
|
13
|
+
const rightWidth = Math.max(20, width - rightX);
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
leftX: 0,
|
|
17
|
+
leftWidth,
|
|
18
|
+
rightX,
|
|
19
|
+
rightWidth,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function paneViewportHeight(): number {
|
|
24
|
+
return Math.max(1, terminalSize().height - 6);
|
|
25
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { htmlToText, wrapText } from "../text.ts";
|
|
2
|
+
import type { Attempt, Outcome, PracticeQuestion, QuestionMeta } from "../types.ts";
|
|
3
|
+
import { answerKeys } from "./question.ts";
|
|
4
|
+
|
|
5
|
+
export type PaneRowKind = "normal" | "muted" | "heading" | "info" | "success" | "danger" | "warning" | "selected";
|
|
6
|
+
|
|
7
|
+
export type PaneTextRow = {
|
|
8
|
+
text: string;
|
|
9
|
+
kind?: PaneRowKind;
|
|
10
|
+
bold?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ChoiceRange = {
|
|
14
|
+
key: string;
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type PracticeAnswerPane = {
|
|
20
|
+
rows: PaneTextRow[];
|
|
21
|
+
choices: ChoiceRange[];
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type ReviewPaneOptions = {
|
|
25
|
+
lastAnswer?: string;
|
|
26
|
+
lastCorrect?: boolean;
|
|
27
|
+
elapsed: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function practiceAnswerPaneRows(question: PracticeQuestion, selected: number, width: number): PracticeAnswerPane {
|
|
31
|
+
const rows: PaneTextRow[] = [];
|
|
32
|
+
const choices: ChoiceRange[] = [];
|
|
33
|
+
const keys = answerKeys(question);
|
|
34
|
+
|
|
35
|
+
for (const [index, key] of keys.entries()) {
|
|
36
|
+
const selectedChoice = index === selected;
|
|
37
|
+
const marker = selectedChoice ? ">" : " ";
|
|
38
|
+
const start = rows.length;
|
|
39
|
+
appendWrapped(rows, `${marker} ${key}. ${htmlToText(question.detail.answerOptions[key])}`, width, {
|
|
40
|
+
kind: selectedChoice ? "selected" : "normal",
|
|
41
|
+
bold: selectedChoice,
|
|
42
|
+
});
|
|
43
|
+
choices.push({ key, start, end: rows.length - 1 });
|
|
44
|
+
|
|
45
|
+
if (index < keys.length - 1) {
|
|
46
|
+
rows.push({ text: "" });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return { rows, choices };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function reviewPaneRows(question: PracticeQuestion, options: ReviewPaneOptions, width: number): PaneTextRow[] {
|
|
54
|
+
const rows: PaneTextRow[] = [];
|
|
55
|
+
const correct = options.lastCorrect ? "correct" : "incorrect";
|
|
56
|
+
const resultKind = options.lastCorrect ? "success" : "danger";
|
|
57
|
+
|
|
58
|
+
rows.push({ text: correct.toUpperCase(), kind: resultKind, bold: true });
|
|
59
|
+
rows.push({ text: `your answer: ${options.lastAnswer ?? "-"}`, kind: resultKind });
|
|
60
|
+
rows.push({ text: `correct: ${question.detail.correct_answer.join(", ")}`, kind: "success" });
|
|
61
|
+
rows.push({ text: `time: ${options.elapsed}`, kind: "info" });
|
|
62
|
+
appendGap(rows);
|
|
63
|
+
appendMetadataRows(rows, question.meta);
|
|
64
|
+
appendGap(rows);
|
|
65
|
+
rows.push({ text: "rationale", kind: "heading", bold: true });
|
|
66
|
+
appendWrapped(rows, htmlToText(question.detail.rationale), width);
|
|
67
|
+
|
|
68
|
+
return rows;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function detailPaneRows(question: PracticeQuestion, attempt: Attempt | undefined, width: number): PaneTextRow[] {
|
|
72
|
+
const rows: PaneTextRow[] = [];
|
|
73
|
+
appendMetadataRows(rows, question.meta, attempt);
|
|
74
|
+
appendGap(rows);
|
|
75
|
+
rows.push({ text: `correct: ${question.detail.correct_answer.join(", ")}`, kind: "success", bold: true });
|
|
76
|
+
rows.push({ text: "answer key", kind: "heading", bold: true });
|
|
77
|
+
|
|
78
|
+
for (const key of answerKeys(question)) {
|
|
79
|
+
const correct = question.detail.correct_answer.includes(key);
|
|
80
|
+
const label = correct ? "*" : " ";
|
|
81
|
+
appendWrapped(rows, `${label} ${key}. ${htmlToText(question.detail.answerOptions[key])}`, width, {
|
|
82
|
+
kind: correct ? "success" : "muted",
|
|
83
|
+
bold: correct,
|
|
84
|
+
});
|
|
85
|
+
rows.push({ text: "" });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (question.detail.rationale) {
|
|
89
|
+
appendGap(rows);
|
|
90
|
+
rows.push({ text: "rationale", kind: "heading", bold: true });
|
|
91
|
+
appendWrapped(rows, htmlToText(question.detail.rationale), width);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return trimTrailingBlankRows(rows);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function formatDomain(meta: QuestionMeta): string {
|
|
98
|
+
return meta.primary_class_cd_desc ? `${meta.primary_class_cd} ${meta.primary_class_cd_desc}` : meta.primary_class_cd;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function formatSkill(meta: QuestionMeta): string {
|
|
102
|
+
return meta.skill_desc ? `${meta.skill_cd} ${meta.skill_desc}` : meta.skill_cd;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function outcomeKind(outcome: Outcome): PaneRowKind {
|
|
106
|
+
if (outcome === "correct") {
|
|
107
|
+
return "success";
|
|
108
|
+
}
|
|
109
|
+
if (outcome === "incorrect") {
|
|
110
|
+
return "danger";
|
|
111
|
+
}
|
|
112
|
+
return "warning";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function difficultyKind(difficulty: string): PaneRowKind {
|
|
116
|
+
if (difficulty === "E") {
|
|
117
|
+
return "success";
|
|
118
|
+
}
|
|
119
|
+
if (difficulty === "H") {
|
|
120
|
+
return "danger";
|
|
121
|
+
}
|
|
122
|
+
return "warning";
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function appendMetadataRows(rows: PaneTextRow[], meta: QuestionMeta, attempt?: Attempt): void {
|
|
126
|
+
if (attempt) {
|
|
127
|
+
rows.push({ text: attempt.outcome.toUpperCase(), kind: outcomeKind(attempt.outcome), bold: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
rows.push({ text: formatDomain(meta), kind: "info" });
|
|
131
|
+
rows.push({ text: formatSkill(meta), kind: "info" });
|
|
132
|
+
rows.push({ text: `difficulty ${meta.difficulty} | ${meta.questionId}`, kind: difficultyKind(meta.difficulty) });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function appendWrapped(rows: PaneTextRow[], value: string | undefined, width: number, row: Omit<PaneTextRow, "text"> = {}): void {
|
|
136
|
+
for (const text of wrapText(value ?? "", width)) {
|
|
137
|
+
rows.push({ ...row, text });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function appendGap(rows: PaneTextRow[]): void {
|
|
142
|
+
rows.push({ text: "" });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function trimTrailingBlankRows(rows: PaneTextRow[]): PaneTextRow[] {
|
|
146
|
+
while (rows.at(-1)?.text === "") {
|
|
147
|
+
rows.pop();
|
|
148
|
+
}
|
|
149
|
+
return rows;
|
|
150
|
+
}
|