@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.
- package/README.md +27 -58
- package/SECURITY.md +16 -45
- package/dist/app/app.js +167 -64
- package/dist/app/chrome.js +67 -50
- package/dist/app/fields/list-field.js +12 -25
- package/dist/app/fields/text-field.js +3 -8
- package/dist/app/frame.js +16 -23
- package/dist/app/keys.js +110 -2
- package/dist/app/views/docs-render.js +34 -26
- package/dist/app/views/docs.js +280 -68
- package/dist/app/views/events-render.js +21 -27
- package/dist/app/views/events.js +57 -33
- package/dist/app/views/home.js +67 -47
- package/dist/app/views/schedule-grid-cursor.js +9 -18
- package/dist/app/views/schedule-render.js +51 -74
- package/dist/app/views/schedule.js +246 -101
- package/dist/app/views/settings-render.js +8 -19
- package/dist/app/views/settings.js +92 -17
- package/dist/auth/cookie-transport.js +31 -32
- package/dist/auth/errors.js +3 -1
- package/dist/auth/nbt-auth.js +42 -25
- package/dist/auth/session-store.js +17 -9
- package/dist/cli.js +570 -0
- package/dist/config/data.js +9 -11
- package/dist/config/preferences.js +21 -7
- package/dist/core/calendar-day.js +37 -0
- package/dist/core/canvas.js +1 -0
- package/dist/core/capabilities.js +6 -3
- package/dist/core/components/confirm.js +9 -8
- package/dist/core/components/menu.js +64 -38
- package/dist/core/components/messages.js +12 -4
- package/dist/core/components/painter.js +3 -1
- package/dist/core/components/spinner.js +34 -7
- package/dist/core/components/text-input.js +24 -18
- package/dist/core/icons.js +2 -2
- package/dist/core/logo.js +23 -5
- package/dist/core/motion.js +25 -19
- package/dist/core/text.js +186 -69
- package/dist/core/theme.js +0 -28
- package/dist/core/transitions.js +2 -2
- package/dist/core/ui.js +15 -13
- package/dist/core/vim-keys.js +156 -19
- package/dist/features/about.js +23 -0
- package/dist/features/calendar-heatmap.js +16 -40
- package/dist/features/calendar-query.js +1 -2
- package/dist/features/calendar-store.js +27 -0
- package/dist/features/calendar.js +66 -190
- package/dist/features/docs-client.js +225 -0
- package/dist/features/docs.js +615 -298
- package/dist/features/links.js +44 -29
- package/dist/features/schedule-render.js +65 -101
- package/dist/features/schedule-store.js +51 -9
- package/dist/features/schedule-view.js +46 -213
- package/dist/features/status.js +117 -60
- package/dist/features/student-timetable.js +74 -97
- package/dist/features/theme.js +9 -5
- package/dist/features/timetable-sanitize.js +40 -0
- package/dist/features/update.js +12 -29
- package/dist/i18n/index.js +83 -19
- package/dist/i18n/locales/en.json +8 -3
- package/dist/i18n/locales/zh.json +8 -3
- package/dist/index.js +6 -474
- package/dist/logo/ca-dotmatrix.txt +16 -18
- package/dist/main.js +7 -48
- package/package.json +28 -18
- package/bin/nbtca-welcome.js +0 -2
- package/dist/core/components/screen.js +0 -18
- package/dist/core/menu.js +0 -68
- package/dist/features/schedule-query.js +0 -47
- package/dist/features/settings.js +0 -127
- package/dist/logo/ca-logo.png +0 -0
package/dist/app/chrome.js
CHANGED
|
@@ -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 +
|
|
12
|
-
|
|
13
|
-
|
|
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 +
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
103
|
-
|
|
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({
|
|
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
|
-
|
|
135
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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
|
-
|
|
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) =>
|
|
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':
|
|
24
|
-
|
|
25
|
-
case '
|
|
26
|
-
|
|
27
|
-
case '
|
|
28
|
-
|
|
29
|
-
|
|
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 =
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
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 =
|
|
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;
|