@nbtca/prompt 1.3.2 → 1.4.1

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 (65) hide show
  1. package/README.md +44 -0
  2. package/SECURITY.md +47 -0
  3. package/dist/app/app.js +202 -0
  4. package/dist/app/chrome.js +104 -0
  5. package/dist/app/fields/list-field.js +174 -0
  6. package/dist/app/fields/text-field.js +38 -0
  7. package/dist/app/frame.js +48 -0
  8. package/dist/app/keys.js +20 -0
  9. package/dist/app/tabs.js +11 -0
  10. package/dist/app/view.js +1 -0
  11. package/dist/app/views/docs-render.js +82 -0
  12. package/dist/app/views/docs.js +457 -0
  13. package/dist/app/views/events-render.js +111 -0
  14. package/dist/app/views/events.js +228 -0
  15. package/dist/app/views/home.js +236 -0
  16. package/dist/app/views/schedule-grid-cursor.js +52 -0
  17. package/dist/app/views/schedule-render.js +317 -0
  18. package/dist/app/views/schedule.js +472 -0
  19. package/dist/app/views/settings-render.js +53 -0
  20. package/dist/app/views/settings.js +153 -0
  21. package/dist/auth/cookie-transport.js +222 -0
  22. package/dist/auth/errors.js +18 -0
  23. package/dist/auth/nbt-auth.js +239 -0
  24. package/dist/auth/session-store.js +118 -0
  25. package/dist/config/paths.js +22 -2
  26. package/dist/core/canvas.js +23 -0
  27. package/dist/core/capabilities.js +42 -0
  28. package/dist/core/components/confirm.js +75 -0
  29. package/dist/core/components/input-session.js +24 -0
  30. package/dist/core/components/menu.js +122 -0
  31. package/dist/core/components/messages.js +16 -0
  32. package/dist/core/components/note.js +18 -0
  33. package/dist/core/components/painter.js +26 -0
  34. package/dist/core/components/screen.js +18 -0
  35. package/dist/core/components/spinner.js +47 -0
  36. package/dist/core/components/text-input.js +98 -0
  37. package/dist/core/logo.js +40 -15
  38. package/dist/core/menu.js +24 -6
  39. package/dist/core/motion.js +86 -0
  40. package/dist/core/text.js +127 -5
  41. package/dist/core/theme.js +61 -0
  42. package/dist/core/transitions.js +19 -0
  43. package/dist/core/ui.js +5 -29
  44. package/dist/features/calendar-heatmap.js +29 -27
  45. package/dist/features/calendar-query.js +50 -0
  46. package/dist/features/calendar.js +192 -98
  47. package/dist/features/docs.js +258 -55
  48. package/dist/features/links.js +7 -4
  49. package/dist/features/schedule-query.js +47 -0
  50. package/dist/features/schedule-render.js +574 -0
  51. package/dist/features/schedule-store.js +73 -0
  52. package/dist/features/schedule-view.js +260 -0
  53. package/dist/features/settings.js +41 -30
  54. package/dist/features/status.js +37 -13
  55. package/dist/features/student-timetable.js +346 -0
  56. package/dist/features/update.js +16 -8
  57. package/dist/i18n/locales/en.json +149 -6
  58. package/dist/i18n/locales/zh.json +149 -6
  59. package/dist/index.js +59 -5
  60. package/dist/logo/ca-dotmatrix-large.txt +26 -0
  61. package/dist/logo/ca-dotmatrix-small.txt +12 -0
  62. package/dist/logo/ca-dotmatrix.txt +18 -16
  63. package/dist/logo/ca-logo.png +0 -0
  64. package/dist/main.js +33 -13
  65. package/package.json +10 -7
@@ -0,0 +1,472 @@
1
+ import path from 'node:path';
2
+ import { createNbtTimetableClient, timetableToIcs, } from '@nbtca/nbtcal/timetable';
3
+ import { captureFooterHint, passiveFooterHint } from '../chrome.js';
4
+ import { ListField, computeMaxVisible } from '../fields/list-field.js';
5
+ import { TextField } from '../fields/text-field.js';
6
+ import { renderSchedule, hubShortcuts } from './schedule-render.js';
7
+ import { defaultGridCursor, handleGridKey } from './schedule-grid-cursor.js';
8
+ import { setVimKeysActive } from '../../core/vim-keys.js';
9
+ import { t } from '../../i18n/index.js';
10
+ import { AuthError } from '../../auth/errors.js';
11
+ import { loginWithStudentPassword, restoreNbtSession } from '../../auth/nbt-auth.js';
12
+ import { createSessionStore } from '../../auth/session-store.js';
13
+ import { resolveTerm, relevantTerms, writePrivateIcs, isSessionExpired, JWXT_ORIGIN, safeMessage, } from '../../features/student-timetable.js';
14
+ import { termKey, loadWeekOne, saveWeekOne, saveTimetableCache, saveCurrentPointer, loadCurrentPointer, loadTimetableCache, clearScheduleCache, } from '../../features/schedule-store.js';
15
+ import { loadCalendarOrThrow, toDisplayEvent } from '../../features/calendar.js';
16
+ import { currentAcademicWindow, inferWeekOneMonday, isAcademicBreakEvent, } from '@nbtca/nbtcal';
17
+ import { currentWeekNumber, campusWeekday } from '../../features/schedule-query.js';
18
+ let state = { mode: 'loading' };
19
+ let session = null;
20
+ let client = null;
21
+ let catalog = [];
22
+ let pendingId = '';
23
+ function isTimetableLike(value) {
24
+ return !!value && typeof value === 'object'
25
+ && Array.isArray(value.meetings)
26
+ && Array.isArray(value.periods);
27
+ }
28
+ function returnToHub() {
29
+ const tt = state.timetable;
30
+ const backKey = state.key;
31
+ const backWeekOne = state.weekOne;
32
+ if (tt && backKey && backWeekOne) {
33
+ // Carries over the existing cursor rather than resetting it -- closing a
34
+ // meeting's detail card (or backing out of term density/unresolved)
35
+ // should leave the student's grid navigation exactly where it was, not
36
+ // silently jump back to today.
37
+ state = {
38
+ mode: 'hub', key: backKey, term: state.term, weekOne: backWeekOne, timetable: tt,
39
+ gridCursor: state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods),
40
+ };
41
+ return true;
42
+ }
43
+ return false;
44
+ }
45
+ function goToLoginId(errorMessage) {
46
+ pendingId = '';
47
+ setVimKeysActive(false);
48
+ state = {
49
+ mode: 'needsLoginId',
50
+ errorMessage,
51
+ idField: new TextField({ message: t().timetable.studentId, placeholder: t().timetable.studentIdHint }),
52
+ };
53
+ }
54
+ function buildPublicField() {
55
+ const trans = t();
56
+ return new ListField({
57
+ title: trans.timetable.menuEntry,
58
+ options: [{ value: 'login', label: trans.timetable.publicLoginAction }],
59
+ footer: trans.menu.hintMove,
60
+ });
61
+ }
62
+ // A glance-panel ceiling, not "browse everything" (matches the same-purpose
63
+ // constants in events.ts/home.ts) — renderPublicBody trims further based on
64
+ // the real ctx.bodyRows.
65
+ const PUBLIC_UPCOMING_FETCH_CAP = 15;
66
+ /** The default, no-login Schedule view: public term/week status sourced from
67
+ * the same public calendar feed Events already uses. Login is now something
68
+ * the student opts into from here, not a gate they hit immediately. */
69
+ async function goToPublic(ctx) {
70
+ setVimKeysActive(true);
71
+ state = { mode: 'public', publicField: buildPublicField() };
72
+ ctx.rerender();
73
+ try {
74
+ const cal = await loadCalendarOrThrow();
75
+ const now = new Date();
76
+ const windowEvents = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
77
+ const publicWindow = currentAcademicWindow(windowEvents, now);
78
+ const publicUpcoming = cal.upcoming({ days: 30 })
79
+ .filter((e) => !isAcademicBreakEvent(e))
80
+ .slice(0, PUBLIC_UPCOMING_FETCH_CAP)
81
+ .map(toDisplayEvent);
82
+ state = { ...state, publicWindow, publicUpcoming };
83
+ }
84
+ catch {
85
+ state = { ...state, publicWindow: null };
86
+ }
87
+ ctx.rerender();
88
+ }
89
+ /** Best-effort: try to auto-fill "week one Monday" from the same public
90
+ * calendar feed before falling back to the manual prompt. Never throws —
91
+ * any failure here just means the student sees today's existing prompt. */
92
+ async function tryInferWeekOne() {
93
+ try {
94
+ const cal = await loadCalendarOrThrow();
95
+ const now = new Date();
96
+ // Symmetric window (matches goToPublic's): inferWeekOneMonday can look
97
+ // forward to an *upcoming* semester-start marker while on break (e.g. a
98
+ // student logging in mid-summer, months before the next term's own
99
+ // start date) — a narrow forward window would silently miss exactly the
100
+ // event this is meant to find.
101
+ const events = cal.inRange(new Date(now.getTime() - 400 * 86400000), new Date(now.getTime() + 400 * 86400000));
102
+ return inferWeekOneMonday(events, now);
103
+ }
104
+ catch {
105
+ return null;
106
+ }
107
+ }
108
+ async function afterAuthenticated(ctx, s) {
109
+ // Captured before any state mutation below, so it reflects whether a
110
+ // cached hub was already on screen when this call started (e.g. a
111
+ // background session restore on launch) as opposed to a fresh login
112
+ // (where state is 'authenticating', so hadCache is false).
113
+ const hadCache = state.mode === 'hub';
114
+ session = s;
115
+ client = createNbtTimetableClient(s.timetableTransport, { baseUrl: JWXT_ORIGIN });
116
+ try {
117
+ catalog = await client.listTerms();
118
+ const term = resolveTerm(catalog);
119
+ const key = termKey(term);
120
+ let weekOne = loadWeekOne(key);
121
+ if (!weekOne) {
122
+ weekOne = await tryInferWeekOne();
123
+ if (weekOne)
124
+ saveWeekOne(key, weekOne);
125
+ }
126
+ if (!weekOne) {
127
+ setVimKeysActive(false);
128
+ state = {
129
+ mode: 'needsWeekOne',
130
+ key,
131
+ term,
132
+ errorMessage: t().timetable.weekOneAutoFailed,
133
+ weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
134
+ };
135
+ ctx.rerender();
136
+ return;
137
+ }
138
+ await fetchAndShowHub(ctx, term, key, weekOne);
139
+ }
140
+ catch (err) {
141
+ if (isSessionExpired(err)) {
142
+ // A dead session must be cleared and routed back to the login
143
+ // field — leaving it as a bare error message here was a dead end:
144
+ // the stale session would keep failing the same way on every
145
+ // future launch/tab-switch, and there was no way back into the
146
+ // login form short of quitting the app.
147
+ createSessionStore().clear();
148
+ if (!hadCache)
149
+ goToLoginId(t().timetable.expiredRelogin);
150
+ }
151
+ else if (!hadCache) {
152
+ // Only replace the screen with an error if there was nothing
153
+ // useful showing already — a background refresh failure must not
154
+ // blow away a working cached timetable (matches the same
155
+ // best-effort contract refreshFromNetwork documents below).
156
+ state = { mode: 'error', errorMessage: safeMessage(err) };
157
+ }
158
+ ctx.rerender();
159
+ }
160
+ }
161
+ async function fetchAndShowHub(ctx, term, key, weekOne) {
162
+ if (!client)
163
+ return;
164
+ state = { mode: 'loading', statusMessage: t().calendar.loading };
165
+ ctx.rerender();
166
+ try {
167
+ const timetable = await client.fetchTerm(term);
168
+ saveTimetableCache(key, timetable);
169
+ saveCurrentPointer(key, weekOne);
170
+ state = {
171
+ mode: 'hub', key, term, weekOne, timetable,
172
+ gridCursor: defaultGridCursor(campusWeekday(new Date()), timetable.periods),
173
+ };
174
+ }
175
+ catch (err) {
176
+ if (isSessionExpired(err)) {
177
+ createSessionStore().clear();
178
+ goToLoginId(t().timetable.expiredRelogin);
179
+ }
180
+ else {
181
+ state = { mode: 'error', errorMessage: safeMessage(err) };
182
+ }
183
+ }
184
+ ctx.rerender();
185
+ }
186
+ async function refreshFromNetwork(ctx) {
187
+ const hadCache = state.mode === 'hub';
188
+ try {
189
+ const store = createSessionStore();
190
+ const persisted = store.load();
191
+ if (!persisted) {
192
+ if (!hadCache)
193
+ await goToPublic(ctx);
194
+ return;
195
+ }
196
+ const restored = await restoreNbtSession(persisted);
197
+ await afterAuthenticated(ctx, restored);
198
+ }
199
+ catch (err) {
200
+ if (!hadCache) {
201
+ if (err instanceof AuthError && isSessionExpired(err)) {
202
+ createSessionStore().clear();
203
+ }
204
+ await goToPublic(ctx);
205
+ }
206
+ // best-effort: a cached hub already showed, keep it as-is on refresh failure.
207
+ }
208
+ }
209
+ export const scheduleView = {
210
+ id: 'schedule',
211
+ title: t().timetable.menuEntry,
212
+ async load(ctx) {
213
+ const ptr = loadCurrentPointer();
214
+ const cached = ptr ? loadTimetableCache(ptr.termKey) : null;
215
+ if (ptr && isTimetableLike(cached)) {
216
+ state = {
217
+ mode: 'hub', key: ptr.termKey, weekOne: ptr.weekOneMonday, timetable: cached,
218
+ gridCursor: defaultGridCursor(campusWeekday(new Date()), cached.periods),
219
+ };
220
+ }
221
+ else {
222
+ state = { mode: 'loading' };
223
+ }
224
+ ctx.rerender();
225
+ await refreshFromNetwork(ctx);
226
+ },
227
+ render(ctx) {
228
+ // Sync every visible field's scroll window to the *current* terminal
229
+ // size on every frame (not just construction time) — this is what
230
+ // keeps a long list correctly windowed across a live resize.
231
+ state.termField?.setMaxVisible(computeMaxVisible(ctx.bodyRows));
232
+ return renderSchedule(state, new Date(), ctx.bodyRows, ctx.size.cols);
233
+ },
234
+ capturesInput() {
235
+ return state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
236
+ },
237
+ footerHint(tabCount, cols = Number.POSITIVE_INFINITY) {
238
+ const capturing = state.mode === 'needsLoginId' || state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne';
239
+ if (capturing)
240
+ return captureFooterHint(cols);
241
+ // These three are pure "read this, any key returns to the hub" drill-
242
+ // downs (see handleKey below) — no field to move a cursor within or
243
+ // open an item from, so the generic "move · open" hint would promise
244
+ // keys that don't do that here.
245
+ const passive = state.mode === 'loading' || state.mode === 'authenticating' || state.mode === 'error'
246
+ || state.mode === 'meetingDetail' || state.mode === 'unresolved' || state.mode === 'termDensity';
247
+ return passive ? passiveFooterHint(tabCount, cols) : undefined;
248
+ },
249
+ handleBack(ctx) {
250
+ if (state.mode === 'needsLoginId') {
251
+ void goToPublic(ctx);
252
+ return true;
253
+ }
254
+ if (state.mode === 'needsLoginPassword' || state.mode === 'needsWeekOne') {
255
+ goToLoginId();
256
+ return true;
257
+ }
258
+ if (state.mode === 'meetingDetail') {
259
+ // Esc respects where the detail card was opened from -- from the
260
+ // standalone 'week' mode, it steps back there, not all the way to hub.
261
+ if (state.detailFrom === 'week') {
262
+ state = { ...state, mode: 'week' };
263
+ return true;
264
+ }
265
+ return returnToHub();
266
+ }
267
+ if (state.mode === 'week' || state.mode === 'unresolved' || state.mode === 'termPicker'
268
+ || state.mode === 'termDensity') {
269
+ return returnToHub();
270
+ }
271
+ return false;
272
+ },
273
+ handleKey(key, ctx) {
274
+ switch (state.mode) {
275
+ case 'public': {
276
+ const result = state.publicField?.handleKey(key);
277
+ if (result?.selected === 'login')
278
+ goToLoginId();
279
+ return;
280
+ }
281
+ case 'needsLoginId': {
282
+ const result = state.idField?.handleKey(key);
283
+ if (result?.cancelled) {
284
+ void goToPublic(ctx);
285
+ return;
286
+ }
287
+ if (result?.submitted !== undefined) {
288
+ pendingId = result.submitted;
289
+ state = {
290
+ mode: 'needsLoginPassword',
291
+ passwordField: new TextField({ message: t().timetable.password, placeholder: t().timetable.passwordHint, secret: true }),
292
+ };
293
+ }
294
+ return;
295
+ }
296
+ case 'needsLoginPassword': {
297
+ const result = state.passwordField?.handleKey(key);
298
+ if (result?.cancelled) {
299
+ goToLoginId();
300
+ return;
301
+ }
302
+ if (result?.submitted !== undefined) {
303
+ const password = result.submitted;
304
+ setVimKeysActive(true);
305
+ state = { mode: 'authenticating', statusMessage: t().timetable.loginWillSave };
306
+ ctx.rerender();
307
+ void loginWithStudentPassword(pendingId, password)
308
+ .then(async (s) => {
309
+ createSessionStore().save(await s.snapshot());
310
+ await afterAuthenticated(ctx, s);
311
+ })
312
+ .catch((err) => {
313
+ goToLoginId(safeMessage(err));
314
+ ctx.rerender();
315
+ });
316
+ }
317
+ return;
318
+ }
319
+ case 'needsWeekOne': {
320
+ const result = state.weekOneField?.handleKey(key);
321
+ if (result?.cancelled) {
322
+ goToLoginId();
323
+ return;
324
+ }
325
+ if (result?.submitted !== undefined) {
326
+ const trimmed = result.submitted.trim();
327
+ const valid = /^\d{4}-\d{2}-\d{2}$/.test(trimmed) && !Number.isNaN(new Date(`${trimmed}T00:00:00`).getTime());
328
+ const targetKey = state.key;
329
+ const targetTerm = state.term;
330
+ if (!valid || !targetKey || !targetTerm) {
331
+ state = { ...state, errorMessage: t().timetable.weekOneHint };
332
+ return;
333
+ }
334
+ saveWeekOne(targetKey, trimmed);
335
+ setVimKeysActive(true);
336
+ void fetchAndShowHub(ctx, targetTerm, targetKey, trimmed);
337
+ }
338
+ return;
339
+ }
340
+ case 'hub': {
341
+ const tt = state.timetable;
342
+ const hubKey = state.key;
343
+ const hubWeekOne = state.weekOne;
344
+ if (!tt || !hubKey || !hubWeekOne)
345
+ return;
346
+ {
347
+ const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
348
+ const week = Math.max(1, currentWeekNumber(hubWeekOne, new Date()));
349
+ const nav = handleGridKey(key, cursor, tt, week);
350
+ if (nav.kind === 'moveCursor') {
351
+ state = { ...state, gridCursor: nav.cursor };
352
+ return;
353
+ }
354
+ if (nav.kind === 'openDetail') {
355
+ state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'hub' };
356
+ return;
357
+ }
358
+ }
359
+ const shortcut = hubShortcuts(tt).find((sc) => sc.key === key);
360
+ if (!shortcut)
361
+ return;
362
+ if (shortcut.key === 'w') {
363
+ state = { ...state, mode: 'week' };
364
+ return;
365
+ }
366
+ if (shortcut.key === 't') {
367
+ state = { ...state, mode: 'termDensity' };
368
+ return;
369
+ }
370
+ if (shortcut.key === 'u') {
371
+ state = { ...state, mode: 'unresolved' };
372
+ return;
373
+ }
374
+ if (shortcut.key === 's') {
375
+ const options = relevantTerms(catalog).map((tm) => ({
376
+ value: `${tm.academicYear}:${tm.semester}`,
377
+ label: tm.academicYearLabel,
378
+ hint: tm.current ? t().common.current : undefined,
379
+ }));
380
+ options.push({ value: '__back__', label: t().common.back, hint: undefined });
381
+ state = {
382
+ ...state,
383
+ mode: 'termPicker',
384
+ termField: new ListField({ title: t().timetable.hubSwitchTerm, options, maxVisible: computeMaxVisible(ctx.bodyRows) }),
385
+ };
386
+ return;
387
+ }
388
+ if (shortcut.key === 'e') {
389
+ try {
390
+ const ics = timetableToIcs(tt, { weekOneMonday: hubWeekOne, calendarName: `NBT ${state.term?.academicYearLabel ?? ''}` });
391
+ const out = `timetable-${hubKey}.ics`;
392
+ writePrivateIcs(out, ics);
393
+ state = { ...state, statusMessage: `${t().common.success}: ${path.resolve(out)}` };
394
+ }
395
+ catch {
396
+ state = { ...state, statusMessage: t().timetable.genericError };
397
+ }
398
+ return;
399
+ }
400
+ if (shortcut.key === 'x') {
401
+ createSessionStore().clear();
402
+ clearScheduleCache();
403
+ void session?.close();
404
+ session = null;
405
+ client = null;
406
+ void goToPublic(ctx);
407
+ }
408
+ return;
409
+ }
410
+ case 'week': {
411
+ const tt = state.timetable;
412
+ const weekOne = state.weekOne;
413
+ if (!tt || !weekOne) {
414
+ returnToHub();
415
+ return;
416
+ }
417
+ const cursor = state.gridCursor ?? defaultGridCursor(campusWeekday(new Date()), tt.periods);
418
+ const week = Math.max(1, currentWeekNumber(weekOne, new Date()));
419
+ const nav = handleGridKey(key, cursor, tt, week);
420
+ if (nav.kind === 'moveCursor') {
421
+ state = { ...state, gridCursor: nav.cursor };
422
+ return;
423
+ }
424
+ if (nav.kind === 'openDetail') {
425
+ state = { ...state, mode: 'meetingDetail', detailMeeting: nav.meeting, detailFrom: 'week' };
426
+ return;
427
+ }
428
+ returnToHub();
429
+ return;
430
+ }
431
+ case 'meetingDetail': {
432
+ if (state.detailFrom === 'week') {
433
+ state = { ...state, mode: 'week' };
434
+ return;
435
+ }
436
+ returnToHub();
437
+ return;
438
+ }
439
+ case 'unresolved':
440
+ case 'termDensity': {
441
+ returnToHub();
442
+ return;
443
+ }
444
+ case 'termPicker': {
445
+ const result = state.termField?.handleKey(key);
446
+ if (!result?.selected)
447
+ return;
448
+ if (result.selected === '__back__') {
449
+ returnToHub();
450
+ return;
451
+ }
452
+ const term = resolveTerm(catalog, result.selected);
453
+ const newTermKey = termKey(term);
454
+ const weekOne = loadWeekOne(newTermKey);
455
+ if (!weekOne) {
456
+ setVimKeysActive(false);
457
+ state = {
458
+ mode: 'needsWeekOne',
459
+ key: newTermKey,
460
+ term,
461
+ weekOneField: new TextField({ message: t().timetable.weekOne, placeholder: t().timetable.weekOneHint }),
462
+ };
463
+ return;
464
+ }
465
+ void fetchAndShowHub(ctx, term, newTermKey, weekOne);
466
+ return;
467
+ }
468
+ default:
469
+ return;
470
+ }
471
+ },
472
+ };
@@ -0,0 +1,53 @@
1
+ import { space, type } from '../../core/theme.js';
2
+ import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
3
+ import { renderListFieldWithContext } from '../fields/list-field.js';
4
+ function wrappedIndentedLines(label, cols, style) {
5
+ const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
6
+ const styled = style(label);
7
+ const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
8
+ const indent = preferredIndent
9
+ && visualWidth(styled) > width - visualWidth(preferredIndent)
10
+ && visualWidth(styled) <= width
11
+ ? ''
12
+ : preferredIndent;
13
+ const contentWidth = Math.max(1, width - visualWidth(indent));
14
+ return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
15
+ }
16
+ export function renderSettings(state, bodyRows = Number.POSITIVE_INFINITY, cols = Number.POSITIVE_INFINITY) {
17
+ switch (state.mode) {
18
+ case 'menu': {
19
+ const context = [
20
+ ...(state.statusMessage ? [...wrappedIndentedLines(state.statusMessage, cols, type.hint), ''] : []),
21
+ ];
22
+ return state.menuField
23
+ ? renderListFieldWithContext(context, state.menuField, bodyRows, cols)
24
+ : context;
25
+ }
26
+ case 'language':
27
+ case 'icon':
28
+ case 'color':
29
+ return state.subField?.render(bodyRows, cols) ?? [];
30
+ case 'about': {
31
+ const context = [
32
+ ...(state.aboutLines ?? []).flatMap((line) => (line ? wrappedIndentedLines(line, cols, (value) => value) : [''])),
33
+ '',
34
+ ];
35
+ if (!state.backField)
36
+ return context;
37
+ const fieldLines = state.backField.render(Number.POSITIVE_INFINITY, cols);
38
+ if (!Number.isFinite(bodyRows) || context.length + fieldLines.length <= bodyRows) {
39
+ return [...context, ...fieldLines];
40
+ }
41
+ const rows = Math.max(0, Math.floor(bodyRows));
42
+ const visibleField = fieldLines.length <= rows
43
+ ? fieldLines
44
+ : state.backField.render(rows, cols);
45
+ const content = context.at(-1) === '' ? context.slice(0, -1) : context;
46
+ return content.length > 0
47
+ ? [...visibleField, '', ...content]
48
+ : visibleField;
49
+ }
50
+ default:
51
+ return [];
52
+ }
53
+ }
@@ -0,0 +1,153 @@
1
+ import { ListField } from '../fields/list-field.js';
2
+ import { renderSettings } from './settings-render.js';
3
+ import { applyColorModePreference, loadPreferences, resetPreferences, setColorMode, setIconMode, } from '../../config/preferences.js';
4
+ import { resetIconCache, pickIcon } from '../../core/icons.js';
5
+ import { APP_INFO, URLS } from '../../config/data.js';
6
+ import { t, getCurrentLanguage, setLanguage, clearTranslationCache } from '../../i18n/index.js';
7
+ import { padEndV } from '../../core/text.js';
8
+ let state = { mode: 'menu' };
9
+ function buildMenuField(statusMessage) {
10
+ const trans = t();
11
+ const prefs = loadPreferences();
12
+ const currentLang = getCurrentLanguage();
13
+ const options = [
14
+ { value: 'language', label: trans.language.selectLanguage, hint: currentLang === 'zh' ? trans.language.zh : trans.language.en },
15
+ { value: 'icon', label: trans.theme.iconMode, hint: prefs.iconMode },
16
+ { value: 'color', label: trans.theme.colorMode, hint: prefs.colorMode },
17
+ { value: 'reset', label: trans.theme.resetLabel },
18
+ { value: 'about', label: trans.about.title },
19
+ ];
20
+ return { mode: 'menu', statusMessage, menuField: new ListField({ title: trans.theme.chooseAction, options }) };
21
+ }
22
+ function goToMenu(statusMessage) {
23
+ state = buildMenuField(statusMessage);
24
+ }
25
+ export const settingsView = {
26
+ id: 'settings',
27
+ title: t().menu.settings,
28
+ async load(_ctx) {
29
+ goToMenu();
30
+ },
31
+ render(ctx) {
32
+ return renderSettings(state, ctx.bodyRows, ctx.size.cols);
33
+ },
34
+ capturesInput() {
35
+ return false;
36
+ },
37
+ handleBack() {
38
+ if (state.mode !== 'menu') {
39
+ goToMenu();
40
+ return true;
41
+ }
42
+ return false;
43
+ },
44
+ handleKey(key, _ctx) {
45
+ const trans = t();
46
+ switch (state.mode) {
47
+ case 'menu': {
48
+ const result = state.menuField?.handleKey(key);
49
+ if (!result?.selected)
50
+ return;
51
+ if (result.selected === 'language') {
52
+ const currentLang = getCurrentLanguage();
53
+ const options = [
54
+ { value: 'zh', label: trans.language.zh, hint: currentLang === 'zh' ? trans.common.current : undefined },
55
+ { value: 'en', label: trans.language.en, hint: currentLang === 'en' ? trans.common.current : undefined },
56
+ ];
57
+ state = { mode: 'language', subField: new ListField({ title: trans.language.selectLanguage, options, initialIndex: currentLang === 'en' ? 1 : 0 }) };
58
+ return;
59
+ }
60
+ if (result.selected === 'icon') {
61
+ const prefs = loadPreferences();
62
+ const options = [
63
+ { value: 'auto', label: trans.theme.modeAuto, hint: prefs.iconMode === 'auto' ? trans.common.current : undefined },
64
+ { value: 'ascii', label: trans.theme.modeAscii, hint: prefs.iconMode === 'ascii' ? trans.common.current : undefined },
65
+ { value: 'unicode', label: trans.theme.modeUnicode, hint: prefs.iconMode === 'unicode' ? trans.common.current : undefined },
66
+ ];
67
+ const idx = Math.max(0, options.findIndex((o) => o.value === prefs.iconMode));
68
+ state = { mode: 'icon', subField: new ListField({ title: trans.theme.chooseIconMode, options, initialIndex: idx }) };
69
+ return;
70
+ }
71
+ if (result.selected === 'color') {
72
+ const prefs = loadPreferences();
73
+ const options = [
74
+ { value: 'auto', label: trans.theme.modeAuto, hint: prefs.colorMode === 'auto' ? trans.common.current : undefined },
75
+ { value: 'on', label: trans.theme.modeOn, hint: prefs.colorMode === 'on' ? trans.common.current : undefined },
76
+ { value: 'off', label: trans.theme.modeOff, hint: prefs.colorMode === 'off' ? trans.common.current : undefined },
77
+ ];
78
+ const idx = Math.max(0, options.findIndex((o) => o.value === prefs.colorMode));
79
+ state = { mode: 'color', subField: new ListField({ title: trans.theme.chooseColorMode, options, initialIndex: idx }) };
80
+ return;
81
+ }
82
+ if (result.selected === 'reset') {
83
+ const saved = resetPreferences();
84
+ resetIconCache();
85
+ applyColorModePreference(false);
86
+ goToMenu(saved ? trans.theme.reset : trans.theme.resetSessionOnly);
87
+ return;
88
+ }
89
+ if (result.selected === 'about') {
90
+ const pad = 12;
91
+ const row = (label, value) => `${padEndV(label, pad)}${value}`;
92
+ state = {
93
+ mode: 'about',
94
+ aboutLines: [
95
+ row(trans.about.project, APP_INFO.name),
96
+ row(trans.about.version, `v${APP_INFO.version}`),
97
+ row(trans.about.description, trans.about.descriptionText),
98
+ '',
99
+ row(trans.about.github, APP_INFO.repository),
100
+ row(trans.about.website, URLS.homepage),
101
+ row(trans.about.email, URLS.email),
102
+ '',
103
+ row(trans.about.license, `MIT ${pickIcon('·', '-')} ${trans.about.author}: m1ngsama`),
104
+ ],
105
+ backField: new ListField({ title: trans.about.title, options: [{ value: '__back__', label: trans.common.back }] }),
106
+ };
107
+ }
108
+ return;
109
+ }
110
+ case 'language': {
111
+ const result = state.subField?.handleKey(key);
112
+ if (!result?.selected)
113
+ return;
114
+ const currentLang = getCurrentLanguage();
115
+ if (result.selected !== currentLang) {
116
+ const saved = setLanguage(result.selected);
117
+ clearTranslationCache();
118
+ goToMenu(saved ? t().language.changed : t().language.changedSessionOnly);
119
+ }
120
+ else {
121
+ goToMenu();
122
+ }
123
+ return;
124
+ }
125
+ case 'icon': {
126
+ const result = state.subField?.handleKey(key);
127
+ if (!result?.selected)
128
+ return;
129
+ const saved = setIconMode(result.selected);
130
+ resetIconCache();
131
+ goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly);
132
+ return;
133
+ }
134
+ case 'color': {
135
+ const result = state.subField?.handleKey(key);
136
+ if (!result?.selected)
137
+ return;
138
+ const saved = setColorMode(result.selected);
139
+ applyColorModePreference(false);
140
+ goToMenu(saved ? trans.theme.updated : trans.theme.updatedSessionOnly);
141
+ return;
142
+ }
143
+ case 'about': {
144
+ const result = state.backField?.handleKey(key);
145
+ if (result?.selected === '__back__')
146
+ goToMenu();
147
+ return;
148
+ }
149
+ default:
150
+ return;
151
+ }
152
+ },
153
+ };