@nbtca/prompt 1.4.2 → 1.5.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 (71) hide show
  1. package/README.md +27 -58
  2. package/SECURITY.md +16 -45
  3. package/dist/app/app.js +167 -64
  4. package/dist/app/chrome.js +67 -50
  5. package/dist/app/fields/list-field.js +12 -25
  6. package/dist/app/fields/text-field.js +3 -8
  7. package/dist/app/frame.js +16 -23
  8. package/dist/app/keys.js +110 -2
  9. package/dist/app/views/docs-render.js +34 -26
  10. package/dist/app/views/docs.js +280 -68
  11. package/dist/app/views/events-render.js +21 -27
  12. package/dist/app/views/events.js +57 -33
  13. package/dist/app/views/home.js +67 -47
  14. package/dist/app/views/schedule-grid-cursor.js +9 -18
  15. package/dist/app/views/schedule-render.js +51 -74
  16. package/dist/app/views/schedule.js +246 -101
  17. package/dist/app/views/settings-render.js +8 -19
  18. package/dist/app/views/settings.js +92 -17
  19. package/dist/auth/cookie-transport.js +31 -32
  20. package/dist/auth/errors.js +3 -1
  21. package/dist/auth/nbt-auth.js +42 -25
  22. package/dist/auth/session-store.js +17 -9
  23. package/dist/cli.js +570 -0
  24. package/dist/config/data.js +9 -11
  25. package/dist/config/preferences.js +21 -7
  26. package/dist/core/calendar-day.js +37 -0
  27. package/dist/core/canvas.js +1 -0
  28. package/dist/core/capabilities.js +6 -3
  29. package/dist/core/components/confirm.js +9 -8
  30. package/dist/core/components/menu.js +64 -38
  31. package/dist/core/components/messages.js +12 -4
  32. package/dist/core/components/painter.js +3 -1
  33. package/dist/core/components/spinner.js +34 -7
  34. package/dist/core/components/text-input.js +24 -18
  35. package/dist/core/icons.js +2 -2
  36. package/dist/core/logo.js +23 -5
  37. package/dist/core/motion.js +25 -19
  38. package/dist/core/text.js +186 -69
  39. package/dist/core/theme.js +0 -28
  40. package/dist/core/transitions.js +2 -2
  41. package/dist/core/ui.js +15 -13
  42. package/dist/core/vim-keys.js +156 -19
  43. package/dist/features/about.js +23 -0
  44. package/dist/features/calendar-heatmap.js +16 -40
  45. package/dist/features/calendar-query.js +1 -2
  46. package/dist/features/calendar-store.js +27 -0
  47. package/dist/features/calendar.js +66 -190
  48. package/dist/features/docs-client.js +225 -0
  49. package/dist/features/docs.js +615 -298
  50. package/dist/features/links.js +44 -29
  51. package/dist/features/schedule-render.js +65 -101
  52. package/dist/features/schedule-store.js +51 -9
  53. package/dist/features/schedule-view.js +46 -213
  54. package/dist/features/status.js +117 -60
  55. package/dist/features/student-timetable.js +74 -97
  56. package/dist/features/theme.js +9 -5
  57. package/dist/features/timetable-sanitize.js +40 -0
  58. package/dist/features/update.js +12 -29
  59. package/dist/i18n/index.js +83 -19
  60. package/dist/i18n/locales/en.json +8 -3
  61. package/dist/i18n/locales/zh.json +8 -3
  62. package/dist/index.js +6 -474
  63. package/dist/logo/ca-dotmatrix.txt +16 -18
  64. package/dist/main.js +7 -48
  65. package/package.json +28 -18
  66. package/bin/nbtca-welcome.js +0 -2
  67. package/dist/core/components/screen.js +0 -18
  68. package/dist/core/menu.js +0 -68
  69. package/dist/features/schedule-query.js +0 -47
  70. package/dist/features/settings.js +0 -127
  71. package/dist/logo/ca-logo.png +0 -0
@@ -2,70 +2,89 @@ import { type, space, glyph, brandMark } from '../core/theme.js';
2
2
  import { pickIcon } from '../core/icons.js';
3
3
  import { t } from '../i18n/index.js';
4
4
  import { visualWidth } from '../core/text.js';
5
- /** `renderHeader` always returns exactly this many lines (brand, tabs, rule). */
6
5
  export const HEADER_LINES = 3;
7
- /** `renderFooter` always returns exactly this many lines (rule, keyhints). */
8
6
  export const FOOTER_LINES = 2;
7
+ export function resolveChromeLayout(rows) {
8
+ const height = Math.max(0, Math.floor(rows));
9
+ if (height >= 11)
10
+ return { headerLines: 3, footerLines: 2 };
11
+ if (height >= 9)
12
+ return { headerLines: 3, footerLines: 1 };
13
+ if (height >= 7)
14
+ return { headerLines: 2, footerLines: 1 };
15
+ if (height >= 4)
16
+ return { headerLines: 1, footerLines: 1 };
17
+ if (height >= 2)
18
+ return { headerLines: 1, footerLines: 0 };
19
+ return { headerLines: 0, footerLines: 0 };
20
+ }
21
+ function renderRule(cols) {
22
+ const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : 80;
23
+ const indent = visualWidth(space.indent) < width ? space.indent : '';
24
+ const ruleWidth = Math.max(1, width - visualWidth(indent) * 2);
25
+ return indent + type.hint(glyph.rule().repeat(ruleWidth));
26
+ }
9
27
  function renderTabs(views, active, cols) {
10
28
  const dot = pickIcon('·', '-');
11
- const full = space.indent + views
12
- .map((view) => view.id === active ? type.active(`[${view.title}]`) : type.hint(view.title))
13
- .join(` ${dot} `);
29
+ const full = space.indent +
30
+ views
31
+ .map((view) => (view.id === active ? type.active(`[${view.title}]`) : type.hint(view.title)))
32
+ .join(` ${dot} `);
14
33
  if (visualWidth(full) <= cols)
15
34
  return full;
16
- const compact = space.indent + views
17
- .map((view, index) => view.id === active
18
- ? type.active(`[${index + 1} ${view.title}]`)
19
- : type.hint(String(index + 1)))
20
- .join(` ${dot} `);
35
+ const compact = space.indent +
36
+ views
37
+ .map((view, index) => view.id === active
38
+ ? type.active(`[${index + 1} ${view.title}]`)
39
+ : type.hint(String(index + 1)))
40
+ .join(` ${dot} `);
21
41
  if (visualWidth(compact) <= cols)
22
42
  return compact;
23
- return space.indent + views
24
- .map((view, index) => view.id === active
25
- ? type.active(`[${index + 1}]`)
26
- : type.hint(String(index + 1)))
27
- .join(' ');
43
+ const numeric = space.indent +
44
+ views
45
+ .map((view, index) => view.id === active ? type.active(`[${index + 1}]`) : type.hint(String(index + 1)))
46
+ .join(' ');
47
+ if (visualWidth(numeric) <= cols)
48
+ return numeric;
49
+ const activeIndex = views.findIndex((view) => view.id === active);
50
+ const activeView = views[activeIndex];
51
+ if (!activeView)
52
+ return '';
53
+ const activeWithTitle = `${space.indent}${type.active(`[${activeIndex + 1} ${activeView.title}]`)}`;
54
+ if (visualWidth(activeWithTitle) <= cols)
55
+ return activeWithTitle;
56
+ const activeNumber = `${space.indent}${type.active(`[${activeIndex + 1}]`)}`;
57
+ if (visualWidth(activeNumber) <= cols)
58
+ return activeNumber;
59
+ return type.active(String(activeIndex + 1));
28
60
  }
29
- // The header's persistent brand mark. A literal shrunk-down copy of the
30
- // emblem doesn't survive down to header height (verified: even the boldest
31
- // inner icon alone dissolves into noise below ~12 character-rows), so this
32
- // is a wordmark painted in the same gradient as the startup logo instead --
33
- // ties the two together by color, the one dimension that still reads at
34
- // one line tall, rather than attempting a shape reproduction this small
35
- // can't carry.
36
- export function renderHeader(views, active, cols) {
37
- const brand = `${space.indent}${brandMark('nbtca')}`;
61
+ export function renderHeader(views, active, cols, lineCount = HEADER_LINES) {
62
+ if (lineCount === 0)
63
+ return [];
64
+ const mark = brandMark('nbtca');
65
+ const brand = `${visualWidth(space.indent + mark) <= cols ? space.indent : ''}${mark}`;
38
66
  const tabs = renderTabs(views, active, cols);
39
- const rule = space.indent + type.hint(glyph.rule().repeat(Math.max(1, cols - 6)));
67
+ const rule = renderRule(cols);
68
+ if (lineCount === 1)
69
+ return [tabs];
70
+ if (lineCount === 2)
71
+ return [brand, tabs];
40
72
  return [brand, tabs, rule];
41
73
  }
42
- /** Shared footer hint for any view mode that captures all input (a focused
43
- * text field or a modal-like list) — the only keys that still do something
44
- * are Ctrl-C/Esc/Enter, so this is what every such view's `footerHint()`
45
- * should return instead of each re-declaring an identical string. Digits/Tab
46
- * are deliberately absent: while input is captured they're typed into the
47
- * field, not routed to global tab-switching, so promising them would itself
48
- * be the false-promise this hint exists to avoid. */
49
74
  export function fitFooterHint(cols, ...candidates) {
50
- return candidates.find((candidate) => visualWidth(space.indent + candidate) <= cols)
51
- ?? candidates[candidates.length - 1]
52
- ?? '';
75
+ return (candidates.find((candidate) => visualWidth(space.indent + candidate) <= cols) ??
76
+ candidates[candidates.length - 1] ??
77
+ '');
53
78
  }
54
79
  export function captureFooterHint(cols = Number.POSITIVE_INFINITY) {
55
80
  const trans = t();
56
81
  const dot = pickIcon('·', '-');
57
82
  return fitFooterHint(cols, `Ctrl+C ${trans.common.exit} ${dot} Esc ${trans.common.back} ${dot} Enter ${trans.common.confirm}`, 'Ctrl+C Esc Enter', 'Ctrl+C Esc', 'Ctrl+C');
58
83
  }
59
- /** The "1-N / Tab" tab-switch prefix, factored out so a view's own
60
- * `footerHint()` override can still include it accurately (tab count isn't
61
- * knowable inside a view module otherwise) instead of either hardcoding a
62
- * digit range that goes stale, or dropping a still-true promise entirely. */
63
84
  export function digitTabHint(tabCount) {
64
85
  const dot = pickIcon('·', '-');
65
86
  return tabCount > 1 ? `1-${tabCount} / Tab ${dot} ` : '';
66
87
  }
67
- /** Shared hint for a non-interactive state: digits/Tab still switch tabs,
68
- * while move/open do nothing and must not be advertised. */
69
88
  export function passiveFooterHint(tabCount, cols = Number.POSITIVE_INFINITY) {
70
89
  const trans = t();
71
90
  const dot = pickIcon('·', '-');
@@ -91,14 +110,12 @@ function interactiveFooterHint(tabCount, cols) {
91
110
  ];
92
111
  return fitFooterHint(cols, ...candidates);
93
112
  }
94
- /** `overrideHint`: a view supplies this (via `View.footerHint()`) when the
95
- * generic tab-switching hint would be false — e.g. while a text field has
96
- * focus, digits/Tab/q are typed characters, not shortcuts, and only Ctrl-C/
97
- * Esc/Enter actually do anything. The footer must never promise a key that
98
- * doesn't work. */
99
- export function renderFooter(_active, cols, tabCount, overrideHint) {
100
- const rule = space.indent + type.hint(glyph.rule().repeat(Math.max(1, cols - 6)));
113
+ export function renderFooter(_active, cols, tabCount, overrideHint, lineCount = FOOTER_LINES) {
114
+ if (lineCount === 0)
115
+ return [];
116
+ const rule = renderRule(cols);
101
117
  const hintText = overrideHint ?? interactiveFooterHint(tabCount, cols);
102
- const hint = space.indent + type.hint(hintText);
103
- return [rule, hint];
118
+ const indent = visualWidth(space.indent + hintText) <= cols ? space.indent : '';
119
+ const hint = indent + type.hint(hintText);
120
+ return lineCount === 1 ? [hint] : [rule, hint];
104
121
  }
@@ -3,10 +3,6 @@ import { space, type } from '../../core/theme.js';
3
3
  import { pickIcon } from '../../core/icons.js';
4
4
  import { t, fmt } from '../../i18n/index.js';
5
5
  import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
6
- /** A conservative rows-to-options budget for a ListField that fills a
7
- * view's whole body (title + blank + up to N options + an optional
8
- * more-indicator + footer). Reserves ~4 lines for that non-option chrome
9
- * so the field never itself overflows `bodyRows`. */
10
6
  export function computeMaxVisible(bodyRows) {
11
7
  return Math.max(3, bodyRows - 4);
12
8
  }
@@ -16,10 +12,6 @@ function renderIndentedOutput(value, cols) {
16
12
  const contentWidth = Math.max(1, width - visualWidth(indent));
17
13
  return wrapAnsiToVisualWidth(value, contentWidth).map((line) => `${indent}${line}`);
18
14
  }
19
- /** Non-blocking equivalent of `runMenu`: a view holds one of these in its own
20
- * state and drives it from the app loop's single stdin listener via
21
- * `handleKey`, instead of `runMenu` attaching a second listener and blocking
22
- * on a Promise. */
23
15
  export class ListField {
24
16
  config;
25
17
  index;
@@ -34,18 +26,9 @@ export class ListField {
34
26
  get selectedIndex() {
35
27
  return this.index;
36
28
  }
37
- /** How many options this field actually has — lets a caller reserve
38
- * exactly enough room for this specific menu instead of guessing a
39
- * shared constant that's wrong for every menu of a different size. */
40
29
  get optionCount() {
41
30
  return this.config.options.length;
42
31
  }
43
- /** Updates the visible-row budget in place (re-clamping the scroll window
44
- * so the selection stays visible) instead of losing the field's current
45
- * selection/scroll by rebuilding it. Views call this from their own
46
- * `render(ctx)` on every frame — cheap, and it's what keeps a field's
47
- * window in sync with the *current* terminal size even though the field
48
- * itself was constructed against whatever size was current at the time. */
49
32
  setMaxVisible(maxVisible) {
50
33
  this.maxVisible = maxVisible;
51
34
  this.clampScroll();
@@ -60,7 +43,12 @@ export class ListField {
60
43
  const { title, options, footer } = this.config;
61
44
  const maxVisible = this.maxVisible;
62
45
  if (!maxVisible || options.length <= maxVisible) {
63
- return renderMenu({ title, options, selectedIndex: this.index, footer }, cols).split('\n');
46
+ return renderMenu({
47
+ title,
48
+ options,
49
+ selectedIndex: this.index,
50
+ ...(footer === undefined ? {} : { footer }),
51
+ }, cols).split('\n');
64
52
  }
65
53
  const visible = options.slice(this.scrollTop, this.scrollTop + maxVisible);
66
54
  const lines = renderMenu({
@@ -130,17 +118,18 @@ export class ListField {
130
118
  const parsed = parseKey(key);
131
119
  if (parsed === 'cancel')
132
120
  return { cancelled: true };
133
- if (parsed === 'enter')
134
- return { selected: this.config.options[this.index]?.value };
135
- const next = nextIndex(this.index, parsed, this.config.options.length);
121
+ if (parsed === 'enter') {
122
+ const selected = this.config.options[this.index]?.value;
123
+ return selected === undefined ? {} : { selected };
124
+ }
125
+ const pageSize = Math.max(1, (this.maxVisible ?? 6) - 1);
126
+ const next = nextIndex(this.index, parsed, this.config.options.length, pageSize);
136
127
  if (next !== this.index) {
137
128
  this.index = next;
138
129
  this.clampScroll();
139
130
  }
140
131
  return {};
141
132
  }
142
- /** Keeps `index` within [scrollTop, scrollTop + maxVisible) after any move
143
- * or after maxVisible itself changes (e.g. a terminal resize). */
144
133
  clampScroll() {
145
134
  const maxVisible = this.maxVisible;
146
135
  if (!maxVisible) {
@@ -151,8 +140,6 @@ export class ListField {
151
140
  this.scrollTop = this.index;
152
141
  else if (this.index >= this.scrollTop + maxVisible)
153
142
  this.scrollTop = this.index - maxVisible + 1;
154
- // The window may also need to slide backward if it shrank enough that
155
- // scrollTop..scrollTop+maxVisible now runs past the end of the list.
156
143
  this.scrollTop = Math.max(0, Math.min(this.scrollTop, Math.max(0, this.config.options.length - maxVisible)));
157
144
  }
158
145
  }
@@ -1,9 +1,4 @@
1
1
  import { renderInput, applyInputEvent, parseInputData } from '../../core/components/text-input.js';
2
- /** Non-blocking equivalent of `runTextInput`/`runSecretInput`: a view holds
3
- * one of these and drives it via `handleKey` from the app loop's single
4
- * stdin listener. Does not touch vim-key activation — the owning view is
5
- * responsible for `setVimKeysActive(false)` while a TextField is focused
6
- * (mirrors what `runTextInput` already does for the blocking widget). */
7
2
  export class TextField {
8
3
  config;
9
4
  value = '';
@@ -17,9 +12,9 @@ export class TextField {
17
12
  return renderInput({
18
13
  message: this.config.message,
19
14
  value: this.value,
20
- placeholder: this.config.placeholder,
21
- secret: this.config.secret,
22
- mask: this.config.mask,
15
+ ...(this.config.placeholder === undefined ? {} : { placeholder: this.config.placeholder }),
16
+ ...(this.config.secret === undefined ? {} : { secret: this.config.secret }),
17
+ ...(this.config.mask === undefined ? {} : { mask: this.config.mask }),
23
18
  cols,
24
19
  }).split('\n');
25
20
  }
package/dist/app/frame.js CHANGED
@@ -1,27 +1,9 @@
1
- import { visualWidth } from '../core/text.js';
1
+ import { ansi } from '../core/canvas.js';
2
+ import { clipAnsiToVisualWidth, visualWidth } from '../core/text.js';
2
3
  export function clipToWidth(line, cols) {
3
4
  if (visualWidth(line) <= cols)
4
5
  return line;
5
- let out = '';
6
- let w = 0;
7
- let i = 0;
8
- while (i < line.length) {
9
- const esc = line.slice(i).match(/^\x1b\[[0-9;]*m/);
10
- if (esc) {
11
- out += esc[0];
12
- i += esc[0].length;
13
- continue;
14
- }
15
- const cp = line.codePointAt(i);
16
- const ch = String.fromCodePoint(cp);
17
- const cw = visualWidth(ch);
18
- if (w + cw > cols)
19
- break;
20
- out += ch;
21
- w += cw;
22
- i += ch.length;
23
- }
24
- return out + '\x1b[0m';
6
+ return clipAnsiToVisualWidth(line, cols) + '\x1b[0m';
25
7
  }
26
8
  export function fitLine(line, cols) {
27
9
  const clipped = visualWidth(line) > cols ? clipToWidth(line, cols) : line;
@@ -36,12 +18,23 @@ export function fitBody(lines, height, scroll, cols) {
36
18
  out.push(' '.repeat(cols));
37
19
  return out;
38
20
  }
39
- export function composeFrame(header, body, footer, rows, cols, scroll) {
21
+ export function composeFrameLines(header, body, footer, rows, cols, scroll) {
40
22
  const h = header.map((l) => fitLine(l, cols));
41
23
  const f = footer.map((l) => fitLine(l, cols));
42
24
  const bodyH = Math.max(0, rows - h.length - f.length);
43
25
  const b = fitBody(body, bodyH, scroll, cols);
44
- return [...h, ...b, ...f].slice(0, rows).join('\n');
26
+ return [...h, ...b, ...f].slice(0, rows);
27
+ }
28
+ export function diffFrame(prev, next) {
29
+ if (prev?.length !== next.length)
30
+ return ansi.home + next.join('\n') + ansi.eraseDown;
31
+ let out = '';
32
+ for (let row = 0; row < next.length; row += 1) {
33
+ const line = next[row];
34
+ if (line !== undefined && prev[row] !== line)
35
+ out += ansi.cursorToRow(row + 1) + line;
36
+ }
37
+ return out;
45
38
  }
46
39
  export function computeBodyRows(rows, headerLines, footerLines) {
47
40
  return Math.max(0, rows - headerLines - footerLines);
package/dist/app/keys.js CHANGED
@@ -1,3 +1,106 @@
1
+ const ESC = '\x1b';
2
+ const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
3
+ function isControl(value) {
4
+ const code = value.charCodeAt(0);
5
+ return code <= 0x1f || (code >= 0x7f && code <= 0x9f);
6
+ }
7
+ export function isPrintableKey(value) {
8
+ if (value.length === 0 || value.startsWith(ESC))
9
+ return false;
10
+ return Array.from(value).every((char) => !isControl(char));
11
+ }
12
+ function escapeSequenceLength(value) {
13
+ if (value.length === 1)
14
+ return null;
15
+ const introducer = value[1];
16
+ if (introducer === ESC || (introducer !== undefined && isControl(introducer)))
17
+ return 1;
18
+ if (introducer === '[' || introducer === 'O') {
19
+ for (let index = 2; index < value.length; index += 1) {
20
+ const code = value.charCodeAt(index);
21
+ if (code >= 0x40 && code <= 0x7e)
22
+ return index + 1;
23
+ if (code < 0x20 || code > 0x3f)
24
+ return 1;
25
+ }
26
+ return null;
27
+ }
28
+ if (introducer === ']' || introducer === 'P' || introducer === '^' || introducer === '_') {
29
+ for (let index = 2; index < value.length; index += 1) {
30
+ if (value[index] === '\x07')
31
+ return index + 1;
32
+ if (value[index] === ESC && value[index + 1] === '\\')
33
+ return index + 2;
34
+ }
35
+ return null;
36
+ }
37
+ const segment = GRAPHEME_SEGMENTER.segment(value.slice(1))[Symbol.iterator]().next();
38
+ return segment.done ? null : 1 + segment.value.segment.length;
39
+ }
40
+ export class KeyStreamDecoder {
41
+ decoder = new TextDecoder();
42
+ pending = '';
43
+ get hasPending() {
44
+ return this.pending.length > 0;
45
+ }
46
+ write(data) {
47
+ this.pending += typeof data === 'string' ? data : this.decoder.decode(data, { stream: true });
48
+ return this.drain(false);
49
+ }
50
+ flush() {
51
+ return this.drain(true);
52
+ }
53
+ reset() {
54
+ this.pending = '';
55
+ this.decoder = new TextDecoder();
56
+ }
57
+ drain(flush) {
58
+ const keys = [];
59
+ let offset = 0;
60
+ while (offset < this.pending.length) {
61
+ const value = this.pending.slice(offset);
62
+ const first = value[0];
63
+ if (first === undefined)
64
+ break;
65
+ if (first === ESC) {
66
+ const length = escapeSequenceLength(value);
67
+ if (length === null) {
68
+ if (flush) {
69
+ keys.push(value);
70
+ offset = this.pending.length;
71
+ }
72
+ break;
73
+ }
74
+ keys.push(value.slice(0, length));
75
+ offset += length;
76
+ continue;
77
+ }
78
+ if (isControl(first)) {
79
+ keys.push(first);
80
+ offset += 1;
81
+ continue;
82
+ }
83
+ let end = offset;
84
+ while (end < this.pending.length) {
85
+ const char = this.pending[end];
86
+ if (char === undefined || char === ESC || isControl(char))
87
+ break;
88
+ end += char.length;
89
+ }
90
+ const run = this.pending.slice(offset, end);
91
+ const segments = Array.from(GRAPHEME_SEGMENTER.segment(run), ({ segment }) => segment);
92
+ for (const segment of segments) {
93
+ keys.push(segment);
94
+ offset += segment.length;
95
+ }
96
+ }
97
+ this.pending = this.pending.slice(offset);
98
+ return keys;
99
+ }
100
+ }
101
+ function switchResult(target) {
102
+ return target === undefined ? { handled: true } : { switchTo: target, handled: true };
103
+ }
1
104
  export function routeGlobalKey(key, viewIds, current) {
2
105
  if (key === 'q' || key === '\x03')
3
106
  return { quit: true, handled: true };
@@ -5,7 +108,12 @@ export function routeGlobalKey(key, viewIds, current) {
5
108
  return current === 'home' ? { quit: true, handled: true } : { back: true, handled: true };
6
109
  if (key === '\t') {
7
110
  const i = viewIds.indexOf(current);
8
- return { switchTo: viewIds[(i + 1) % viewIds.length], handled: true };
111
+ return switchResult(viewIds[(i + 1) % viewIds.length]);
112
+ }
113
+ if (key === '\x1b[Z') {
114
+ const i = viewIds.indexOf(current);
115
+ const previous = i < 0 ? viewIds.length - 1 : (i - 1 + viewIds.length) % viewIds.length;
116
+ return switchResult(viewIds[previous]);
9
117
  }
10
118
  if (key === '\x1b[5~')
11
119
  return { scrollBy: -1, handled: true };
@@ -14,7 +122,7 @@ export function routeGlobalKey(key, viewIds, current) {
14
122
  if (/^[1-9]$/.test(key)) {
15
123
  const idx = Number(key) - 1;
16
124
  if (idx < viewIds.length)
17
- return { switchTo: viewIds[idx], handled: true };
125
+ return switchResult(viewIds[idx]);
18
126
  }
19
127
  return { handled: false };
20
128
  }
@@ -1,32 +1,35 @@
1
1
  import { type, space } from '../../core/theme.js';
2
2
  import { t } from '../../i18n/index.js';
3
3
  import { renderListFieldWithContext } from '../fields/list-field.js';
4
- import { visualWidth, wrapAnsiToVisualWidth } from '../../core/text.js';
4
+ import { visualWidth, wrapAnsiToVisualWidth, wrapAnsiWithIndent } from '../../core/text.js';
5
+ import { loadingLines } from '../../core/components/spinner.js';
5
6
  function hintLines(label, cols) {
6
- const width = Number.isFinite(cols) ? Math.max(1, Math.floor(cols)) : Number.POSITIVE_INFINITY;
7
- const styled = type.hint(label);
8
- const preferredIndent = visualWidth(space.indent) < width ? space.indent : '';
9
- const indent = preferredIndent
10
- && visualWidth(styled) > width - visualWidth(preferredIndent)
11
- && visualWidth(styled) <= width
12
- ? ''
13
- : preferredIndent;
14
- const contentWidth = Math.max(1, width - visualWidth(indent));
15
- return wrapAnsiToVisualWidth(styled, contentWidth).map((line) => `${indent}${line}`);
7
+ return wrapAnsiWithIndent(type.hint(label), cols, space.indent);
16
8
  }
17
9
  function renderReader(lines, cols) {
18
10
  const contentWidth = Math.max(1, Math.min(80, cols - visualWidth(space.indent)));
19
- return lines.flatMap((line) => (wrapAnsiToVisualWidth(line, contentWidth).map((part) => `${space.indent}${part}`)));
11
+ return lines.flatMap((line) => wrapAnsiToVisualWidth(line, contentWidth).map((part) => `${space.indent}${part}`));
20
12
  }
21
13
  function listFieldForState(state) {
22
14
  switch (state.mode) {
23
- case 'sections': return state.sectionsField;
24
- case 'files': return state.filesField;
25
- case 'archivedGroups': return state.archivedGroupsField;
26
- case 'archivedFiles': return state.archivedFilesField;
27
- case 'searchResults': return state.searchResultsField;
28
- case 'reader': return state.readerLinksField;
29
- default: return undefined;
15
+ case 'sections':
16
+ return state.sectionsField;
17
+ case 'files':
18
+ return state.filesField;
19
+ case 'archivedGroups':
20
+ return state.archivedGroupsField;
21
+ case 'archivedFiles':
22
+ return state.archivedFilesField;
23
+ case 'searchResults':
24
+ return state.searchResultsField;
25
+ case 'reader':
26
+ return state.readerLinksField;
27
+ case 'error':
28
+ case 'loading':
29
+ case 'readerLoading':
30
+ case 'search':
31
+ case 'searchLoading':
32
+ return undefined;
30
33
  }
31
34
  }
32
35
  export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY) {
@@ -34,7 +37,7 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
34
37
  let lines;
35
38
  switch (state.mode) {
36
39
  case 'loading':
37
- lines = hintLines(trans.common.loading, cols);
40
+ lines = loadingLines(trans.common.loading, cols);
38
41
  break;
39
42
  case 'sections':
40
43
  lines = state.sectionsField?.render(bodyRows, cols) ?? [];
@@ -51,13 +54,20 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
51
54
  case 'search':
52
55
  lines = state.searchField?.render(cols) ?? [];
53
56
  break;
57
+ case 'searchLoading':
58
+ lines = hintLines(trans.docs.searching, cols);
59
+ break;
54
60
  case 'searchResults':
55
- lines = state.searchResultsField ? renderListFieldWithContext([
56
- ...(state.searchResultsEmpty ? [...hintLines(trans.docs.searchNoResults, cols), ''] : []),
57
- ], state.searchResultsField, bodyRows, cols) : [];
61
+ lines = state.searchResultsField
62
+ ? renderListFieldWithContext([
63
+ ...(state.searchResultsEmpty
64
+ ? [...hintLines(trans.docs.searchNoResults, cols), '']
65
+ : []),
66
+ ], state.searchResultsField, bodyRows, cols)
67
+ : [];
58
68
  break;
59
69
  case 'readerLoading':
60
- lines = hintLines(trans.docs.loadingFile, cols);
70
+ lines = loadingLines(trans.docs.loadingFile, cols);
61
71
  break;
62
72
  case 'reader':
63
73
  lines = state.readerLinksField
@@ -66,8 +76,6 @@ export function renderDocs(state, cols = 80, bodyRows = Number.POSITIVE_INFINITY
66
76
  break;
67
77
  case 'error':
68
78
  return hintLines(state.errorMessage ?? trans.docs.loadError, cols);
69
- default:
70
- lines = [];
71
79
  }
72
80
  if (!state.errorMessage)
73
81
  return lines;