@effected/cli 0.10.0 → 0.12.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/Cancelled.js +44 -0
- package/CliAudience.js +178 -0
- package/CliColor.js +13 -19
- package/CliEnv.js +89 -0
- package/CliExit.js +1 -1
- package/CliFailure.js +302 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +346 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +80 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +110 -54
- package/CliTest.js +16 -0
- package/CliTheme.js +141 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +536 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +145 -131
- package/Render.js +255 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +166 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +3089 -169
- package/index.js +19 -1
- package/internal/ansi.js +230 -0
- package/internal/autoFormat.js +34 -0
- package/internal/canPrompt.js +15 -0
- package/internal/counts.js +84 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +195 -0
- package/internal/fallbackAnswer.js +18 -0
- package/internal/fileSink.js +62 -0
- package/internal/format.js +62 -7
- package/internal/layout.js +250 -0
- package/internal/linkScheme.js +30 -0
- package/internal/linkTarget.js +50 -0
- package/internal/logSafety.js +46 -0
- package/internal/renderAnsi.js +52 -0
- package/internal/renderDoc.js +320 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +368 -0
- package/internal/renderPlain.js +50 -0
- package/internal/scanAudience.js +106 -0
- package/internal/splitFrame.js +56 -0
- package/internal/wizardGate.js +18 -0
- package/package.json +40 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +432 -0
- package/ui/CliUiLive.js +446 -0
- package/ui/Confirm.js +245 -0
- package/ui/DocView.js +74 -0
- package/ui/KeyHelp.js +62 -0
- package/ui/KeyTable.js +199 -0
- package/ui/MultiSelect.js +260 -0
- package/ui/Select.js +230 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +290 -0
- package/ui/Toggle.js +32 -0
- package/ui/UiKey.js +44 -0
- package/ui/UiProvider.js +60 -0
- package/ui/UiStreams.js +18 -0
- package/ui/UiTheme.js +119 -0
- package/ui/Viewport.js +204 -0
- package/ui/internal/ErrorBoundary.js +30 -0
- package/ui/internal/Holder.js +74 -0
- package/ui/internal/ScreenContext.js +52 -0
- package/ui/internal/UiProviders.js +21 -0
- package/ui/internal/ink.js +122 -0
- package/ui/internal/inkChalk.js +58 -0
- package/ui/internal/inkConsole.js +146 -0
- package/ui/internal/lazyView.js +74 -0
- package/ui/internal/lineText.js +19 -0
- package/ui/internal/mountPermit.js +16 -0
- package/ui/internal/perfDrain.js +33 -0
- package/ui/internal/processStreams.js +19 -0
- package/ui/internal/renderOptions.js +13 -0
- package/ui/testing/CliUiTest.js +760 -0
- package/ui/testing/fakeStreams.js +79 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing-serializer.d.ts +14 -0
- package/ui-testing-serializer.js +33 -0
- package/ui-testing.d.ts +527 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1790 -0
- package/ui.js +17 -0
package/ui/TextInput.js
ADDED
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
import { inkModules } from "./internal/ink.js";
|
|
3
|
+
import { useScreenGuard } from "./internal/ScreenContext.js";
|
|
4
|
+
import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
|
|
5
|
+
import { UiKey } from "./UiKey.js";
|
|
6
|
+
import { KeyTable } from "./KeyTable.js";
|
|
7
|
+
import { lineText } from "./internal/lineText.js";
|
|
8
|
+
import { KeyHelp } from "./KeyHelp.js";
|
|
9
|
+
|
|
10
|
+
//#region src/ui/TextInput.ts
|
|
11
|
+
const init = (options = {}) => {
|
|
12
|
+
const value = options.initial ?? "";
|
|
13
|
+
return {
|
|
14
|
+
value,
|
|
15
|
+
cursor: value.length,
|
|
16
|
+
submitted: false
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
const segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
20
|
+
/** The grapheme boundary before `at`: the start of the grapheme `at` is in or just after; 0 at the start. */
|
|
21
|
+
const previous = (value, at) => {
|
|
22
|
+
let boundary = 0;
|
|
23
|
+
for (const { index } of segmenter.segment(value)) {
|
|
24
|
+
if (index >= at) break;
|
|
25
|
+
boundary = index;
|
|
26
|
+
}
|
|
27
|
+
return boundary;
|
|
28
|
+
};
|
|
29
|
+
/** The grapheme boundary after `at`: the end of the grapheme that starts at or contains `at`; the length at the end. */
|
|
30
|
+
const following = (value, at) => {
|
|
31
|
+
for (const { index, segment } of segmenter.segment(value)) {
|
|
32
|
+
const end = index + segment.length;
|
|
33
|
+
if (end > at) return end;
|
|
34
|
+
}
|
|
35
|
+
return value.length;
|
|
36
|
+
};
|
|
37
|
+
const insert = (state, text) => ({
|
|
38
|
+
value: state.value.slice(0, state.cursor) + text + state.value.slice(state.cursor),
|
|
39
|
+
cursor: state.cursor + text.length,
|
|
40
|
+
submitted: false
|
|
41
|
+
});
|
|
42
|
+
const CONTROLS = /([\u0000-\u001f\u007f])/;
|
|
43
|
+
const PASTE_CONTROLS = /[\u0000-\u001f\u007f]/g;
|
|
44
|
+
/**
|
|
45
|
+
* The keys one Ink input holds. Text read in one go (a fast typist, a paste) reaches `useInput` as one string, controls
|
|
46
|
+
* and all, so it is split at its control characters: the printable runs are typed whole, `\r` is enter (and ends the
|
|
47
|
+
* input: text after a submit is not typed into a submitted field), a backspace byte is backspace, a line feed is a
|
|
48
|
+
* space (a pasted line break separates words in a one-line field), and a tab or any other control is dropped. A named
|
|
49
|
+
* key, or a Ctrl or Meta combination, is the one key Ink reported.
|
|
50
|
+
*/
|
|
51
|
+
const typedKeys = (input, key) => {
|
|
52
|
+
const single = UiKey.fromInk(input, key);
|
|
53
|
+
if (single?._tag === "Named" || key.ctrl || key.meta || !CONTROLS.test(input) || [...input].length < 2) return single === void 0 ? [] : [single];
|
|
54
|
+
const keys = [];
|
|
55
|
+
for (const piece of input.split(CONTROLS)) {
|
|
56
|
+
if (piece === "") continue;
|
|
57
|
+
if (piece === "\r") {
|
|
58
|
+
keys.push(UiKey.named("enter"));
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
if (piece === "" || piece === "\b") keys.push(UiKey.named("backspace"));
|
|
62
|
+
else if (piece === "\n") keys.push(UiKey.char(" "));
|
|
63
|
+
else if (!CONTROLS.test(piece)) keys.push(UiKey.char(piece));
|
|
64
|
+
}
|
|
65
|
+
return keys;
|
|
66
|
+
};
|
|
67
|
+
const step = (state, key) => {
|
|
68
|
+
if (key._tag === "Char") return insert(state, key.char);
|
|
69
|
+
const { value, cursor } = state;
|
|
70
|
+
switch (key.name) {
|
|
71
|
+
case "space": return insert(state, " ");
|
|
72
|
+
case "backspace": {
|
|
73
|
+
if (cursor === 0) return state;
|
|
74
|
+
const from = previous(value, cursor);
|
|
75
|
+
return {
|
|
76
|
+
value: value.slice(0, from) + value.slice(cursor),
|
|
77
|
+
cursor: from,
|
|
78
|
+
submitted: false
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
case "delete": return cursor === value.length ? state : {
|
|
82
|
+
value: value.slice(0, cursor) + value.slice(following(value, cursor)),
|
|
83
|
+
cursor,
|
|
84
|
+
submitted: false
|
|
85
|
+
};
|
|
86
|
+
case "left": return {
|
|
87
|
+
...state,
|
|
88
|
+
cursor: previous(value, cursor),
|
|
89
|
+
submitted: false
|
|
90
|
+
};
|
|
91
|
+
case "right": return {
|
|
92
|
+
...state,
|
|
93
|
+
cursor: following(value, cursor),
|
|
94
|
+
submitted: false
|
|
95
|
+
};
|
|
96
|
+
case "home": return {
|
|
97
|
+
...state,
|
|
98
|
+
cursor: 0,
|
|
99
|
+
submitted: false
|
|
100
|
+
};
|
|
101
|
+
case "end": return {
|
|
102
|
+
...state,
|
|
103
|
+
cursor: value.length,
|
|
104
|
+
submitted: false
|
|
105
|
+
};
|
|
106
|
+
case "enter": return {
|
|
107
|
+
...state,
|
|
108
|
+
submitted: true
|
|
109
|
+
};
|
|
110
|
+
default: return state;
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
/** Take code points from the end of `text` while they fit in `width` cells. */
|
|
114
|
+
const tail = (text, width) => {
|
|
115
|
+
const points = [...text];
|
|
116
|
+
let out = "";
|
|
117
|
+
let used = 0;
|
|
118
|
+
for (let index = points.length - 1; index >= 0; index--) {
|
|
119
|
+
const point = points[index] ?? "";
|
|
120
|
+
const cells = Fmt.width(point);
|
|
121
|
+
if (used + cells > width) break;
|
|
122
|
+
out = point + out;
|
|
123
|
+
used += cells;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
/** Take code points from the start of `text` while they fit in `width` cells. */
|
|
128
|
+
const head = (text, width) => {
|
|
129
|
+
let out = "";
|
|
130
|
+
let used = 0;
|
|
131
|
+
for (const point of text) {
|
|
132
|
+
const cells = Fmt.width(point);
|
|
133
|
+
if (used + cells > width) break;
|
|
134
|
+
out += point;
|
|
135
|
+
used += cells;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* The text either side of the cursor, scrolled so the line fits `width` cells and the cursor stays in view: a cut
|
|
141
|
+
* edge is marked with the ellipsis, the text after the cursor keeps up to a third of the room, and the text before
|
|
142
|
+
* it the rest.
|
|
143
|
+
*/
|
|
144
|
+
const windowAround = (before, after, width, ellipsis) => {
|
|
145
|
+
if (Fmt.width(before) + Fmt.width(after) <= width) return [before, after];
|
|
146
|
+
const mark = Fmt.width(ellipsis);
|
|
147
|
+
const beforeRoom = width - Math.min(Fmt.width(after), Math.floor(width / 3));
|
|
148
|
+
const shownBefore = Fmt.width(before) <= beforeRoom ? before : beforeRoom > mark ? `${ellipsis}${tail(before, beforeRoom - mark)}` : tail(before, Math.max(0, beforeRoom));
|
|
149
|
+
const room = Math.max(0, width - Fmt.width(shownBefore));
|
|
150
|
+
return [shownBefore, Fmt.width(after) <= room ? after : room > mark ? `${head(after, room - mark)}${ellipsis}` : head(after, room)];
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* The value masked either side of the cursor, one `mask` per grapheme of the whole value: the graphemes that start
|
|
154
|
+
* before the cursor, then the rest, so the two always add up to the value's graphemes, wherever the cursor is.
|
|
155
|
+
*/
|
|
156
|
+
const maskedAround = (value, cursor, mask) => {
|
|
157
|
+
let before = 0;
|
|
158
|
+
let total = 0;
|
|
159
|
+
for (const { index } of segmenter.segment(value)) {
|
|
160
|
+
total++;
|
|
161
|
+
if (index < cursor) before++;
|
|
162
|
+
}
|
|
163
|
+
return [mask.repeat(before), mask.repeat(total - before)];
|
|
164
|
+
};
|
|
165
|
+
/** Shown in the help line only; the input reads every key itself. */
|
|
166
|
+
const HELP = KeyTable.make([{
|
|
167
|
+
keys: ["enter"],
|
|
168
|
+
action: "submit",
|
|
169
|
+
help: "submit"
|
|
170
|
+
}]);
|
|
171
|
+
/**
|
|
172
|
+
* One line of text: a pure reducer, a view and a ready-made screen.
|
|
173
|
+
*
|
|
174
|
+
* @remarks
|
|
175
|
+
* Every typed character is text, `q` included: the input binds no letter, so Esc and Ctrl-C are the screen's root
|
|
176
|
+
* keys and still cancel with `"escape"` and `"interrupt"`.
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* import { CliUi, TextInput } from "@effected/cli/ui"
|
|
181
|
+
* import { Effect } from "effect"
|
|
182
|
+
*
|
|
183
|
+
* const askName = Effect.gen(function* () {
|
|
184
|
+
* const name = yield* CliUi.run(
|
|
185
|
+
* TextInput.screen({
|
|
186
|
+
* message: "Package name?",
|
|
187
|
+
* placeholder: "my-package",
|
|
188
|
+
* validate: (value) => (value.trim() === "" ? "A name is required" : undefined),
|
|
189
|
+
* }),
|
|
190
|
+
* )
|
|
191
|
+
* return name
|
|
192
|
+
* })
|
|
193
|
+
* ```
|
|
194
|
+
*
|
|
195
|
+
* @public
|
|
196
|
+
*/
|
|
197
|
+
var TextInput = class TextInput {
|
|
198
|
+
constructor() {}
|
|
199
|
+
/**
|
|
200
|
+
* An input holding `initial`, the cursor after it.
|
|
201
|
+
*
|
|
202
|
+
* @param options - the starting text
|
|
203
|
+
*/
|
|
204
|
+
static init = init;
|
|
205
|
+
/**
|
|
206
|
+
* Apply a key: a typed character (any, `q` included) or space is inserted at the cursor; backspace and delete
|
|
207
|
+
* remove the grapheme before or after it; left and right move it a grapheme, home and end to either end, clamped
|
|
208
|
+
* to the text; enter marks it submitted. Every other key changes nothing.
|
|
209
|
+
*
|
|
210
|
+
* @param state - where the input is
|
|
211
|
+
* @param key - the key pressed
|
|
212
|
+
*/
|
|
213
|
+
static step = step;
|
|
214
|
+
/**
|
|
215
|
+
* Draw the input: the message, the value with the cursor shown as `▏` (`|` under ASCII glyphs, so it stays visible
|
|
216
|
+
* without colour), or one mask per grapheme in its place with `mask`, the placeholder while empty, a validation
|
|
217
|
+
* message in the error token, and the key help. Enter
|
|
218
|
+
* submits when `validate` passes; otherwise its message is shown until the next key other than enter, or a paste.
|
|
219
|
+
*
|
|
220
|
+
* @remarks
|
|
221
|
+
* Text read in one go (a fast typist) is typed as it reads: printable runs are inserted whole, a return submits
|
|
222
|
+
* what came before it (anything after it is dropped), a backspace byte deletes, a line feed becomes a space, and a
|
|
223
|
+
* tab or other control character is dropped. A bracketed paste is inserted as text, its line breaks as spaces, and
|
|
224
|
+
* never submits.
|
|
225
|
+
*
|
|
226
|
+
* @param props - the message, the starting text, the placeholder, the validator and where the value goes
|
|
227
|
+
*/
|
|
228
|
+
static View = (props) => {
|
|
229
|
+
const { ink, react } = inkModules();
|
|
230
|
+
const glyphs = useGlyphs();
|
|
231
|
+
const { columns } = useTerminalSize();
|
|
232
|
+
const [state, setState] = react.useState(() => init(props.initial === void 0 ? {} : { initial: props.initial }));
|
|
233
|
+
const [error, setError] = react.useState(void 0);
|
|
234
|
+
const { validate, onSubmit } = props;
|
|
235
|
+
react.useEffect(() => {
|
|
236
|
+
if (!state.submitted) return;
|
|
237
|
+
const problem = validate?.(state.value);
|
|
238
|
+
if (problem === void 0) onSubmit(state.value);
|
|
239
|
+
else {
|
|
240
|
+
setError(problem);
|
|
241
|
+
setState((current) => ({
|
|
242
|
+
...current,
|
|
243
|
+
submitted: false
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
}, [state.submitted]);
|
|
247
|
+
const guard = useScreenGuard();
|
|
248
|
+
ink.usePaste(guard((text) => {
|
|
249
|
+
const typed = text.replace(/\r\n|\r|\n/g, " ").replace(PASTE_CONTROLS, "");
|
|
250
|
+
if (typed === "") return;
|
|
251
|
+
setError(void 0);
|
|
252
|
+
setState((current) => step(current, UiKey.char(typed)));
|
|
253
|
+
}));
|
|
254
|
+
ink.useInput(guard((input, key) => {
|
|
255
|
+
const keys = typedKeys(input, key);
|
|
256
|
+
if (keys.some((pressed) => pressed._tag === "Named" && (pressed.name === "escape" || pressed.name === "ctrl+c"))) return;
|
|
257
|
+
if (keys.some((pressed) => !(pressed._tag === "Named" && pressed.name === "enter"))) setError(void 0);
|
|
258
|
+
for (const pressed of keys) setState((current) => step(current, pressed));
|
|
259
|
+
}));
|
|
260
|
+
const cursorGlyph = glyphs.kind === "unicode" ? "▏" : "|";
|
|
261
|
+
const latched = react.useRef(false);
|
|
262
|
+
if (state.value === "") latched.current = false;
|
|
263
|
+
else if (typeof props.mask === "function" && !latched.current && props.mask(state.value)) latched.current = true;
|
|
264
|
+
const masking = typeof props.mask === "function" ? latched.current : props.mask;
|
|
265
|
+
const mask = masking === void 0 || masking === false ? void 0 : masking === true ? glyphs.kind === "unicode" ? "•" : "*" : lineText(masking);
|
|
266
|
+
const [shownBefore, shownAfter] = mask === void 0 ? [state.value.slice(0, state.cursor), state.value.slice(state.cursor)] : maskedAround(state.value, state.cursor, mask);
|
|
267
|
+
const [before, after] = windowAround(shownBefore, shownAfter, columns - Fmt.width(cursorGlyph), glyphs.ellipsis);
|
|
268
|
+
return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, { ellipsis: glyphs.ellipsis })), react.createElement(ink.Text, null, before, cursorGlyph, after, state.value === "" && props.placeholder !== void 0 ? react.createElement(Styled, { token: "muted" }, Fmt.truncate(lineText(props.placeholder), Math.max(0, columns - Fmt.width(cursorGlyph)), { ellipsis: glyphs.ellipsis })) : null), error === void 0 ? null : react.createElement(Styled, { token: "error" }, Fmt.truncate(lineText(error), columns, { ellipsis: glyphs.ellipsis })), react.createElement(KeyHelp, { tables: [HELP] }));
|
|
269
|
+
};
|
|
270
|
+
/**
|
|
271
|
+
* A ready-made screen for `CliUi.run`: the input, resolving with the submitted text.
|
|
272
|
+
*
|
|
273
|
+
* @remarks
|
|
274
|
+
* With `mask`, a secret is drawn as one mask per grapheme and never as itself, while the screen still resolves with
|
|
275
|
+
* the real text:
|
|
276
|
+
*
|
|
277
|
+
* ```ts
|
|
278
|
+
* const token = CliUi.run(TextInput.screen({ message: "Token reference?", mask: true }), { clear: true })
|
|
279
|
+
* ```
|
|
280
|
+
*
|
|
281
|
+
* @param options - the message, the starting text, the placeholder, the validator and the mask
|
|
282
|
+
*/
|
|
283
|
+
static screen = (options) => (control) => inkModules().react.createElement(TextInput.View, {
|
|
284
|
+
...options,
|
|
285
|
+
onSubmit: control.resolve
|
|
286
|
+
});
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
//#endregion
|
|
290
|
+
export { TextInput };
|
package/ui/Toggle.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
import { inkModules } from "./internal/ink.js";
|
|
3
|
+
import { Styled, useGlyphs, useTerminalSize } from "./UiTheme.js";
|
|
4
|
+
import { lineText } from "./internal/lineText.js";
|
|
5
|
+
|
|
6
|
+
//#region src/ui/Toggle.ts
|
|
7
|
+
/**
|
|
8
|
+
* An on/off row: a check glyph and a label.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
var Toggle = class {
|
|
13
|
+
constructor() {}
|
|
14
|
+
/**
|
|
15
|
+
* Draw a toggle row: `◉` on or `◯` off (`[x]` and `[ ]` under ASCII glyphs), then the label, cut to the width with
|
|
16
|
+
* the glyph set's ellipsis. A highlighted row starts with the arrow glyph and is painted with the accent token.
|
|
17
|
+
*
|
|
18
|
+
* @param props - the label, the value and whether the row is highlighted
|
|
19
|
+
*/
|
|
20
|
+
static View = (props) => {
|
|
21
|
+
const { ink, react } = inkModules();
|
|
22
|
+
const glyphs = useGlyphs();
|
|
23
|
+
const { columns } = useTerminalSize();
|
|
24
|
+
const check = props.value ? glyphs.kind === "unicode" ? "◉" : "[x]" : glyphs.kind === "unicode" ? "◯" : "[ ]";
|
|
25
|
+
const lead = props.highlighted ? glyphs.arrow : " ".repeat(Fmt.width(glyphs.arrow));
|
|
26
|
+
const text = Fmt.truncate(`${lead} ${check} ${lineText(props.label)}`, columns, { ellipsis: glyphs.ellipsis });
|
|
27
|
+
return props.highlighted ? react.createElement(Styled, { token: "accent" }, text) : react.createElement(ink.Text, null, text);
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
//#endregion
|
|
32
|
+
export { Toggle };
|
package/ui/UiKey.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
//#region src/ui/UiKey.ts
|
|
2
|
+
const named = (name) => ({
|
|
3
|
+
_tag: "Named",
|
|
4
|
+
name
|
|
5
|
+
});
|
|
6
|
+
const CONTROL = /[\u0000-\u001f\u007f]/;
|
|
7
|
+
/**
|
|
8
|
+
* Normalising Ink's input into {@link UiKey}s.
|
|
9
|
+
*
|
|
10
|
+
* @public
|
|
11
|
+
*/
|
|
12
|
+
const UiKey = {
|
|
13
|
+
fromInk: (input, key) => {
|
|
14
|
+
if (key.upArrow) return named("up");
|
|
15
|
+
if (key.downArrow) return named("down");
|
|
16
|
+
if (key.leftArrow) return named("left");
|
|
17
|
+
if (key.rightArrow) return named("right");
|
|
18
|
+
if (key.pageUp) return named("pageup");
|
|
19
|
+
if (key.pageDown) return named("pagedown");
|
|
20
|
+
if (key.home) return named("home");
|
|
21
|
+
if (key.end) return named("end");
|
|
22
|
+
if (key.return) return named("enter");
|
|
23
|
+
if (key.escape) return named("escape");
|
|
24
|
+
if (key.ctrl && input === "c") return named("ctrl+c");
|
|
25
|
+
if (key.tab) return named(key.shift ? "shift+tab" : "tab");
|
|
26
|
+
if (key.backspace) return named("backspace");
|
|
27
|
+
if (key.delete) return named("delete");
|
|
28
|
+
if (key.ctrl || key.meta) return void 0;
|
|
29
|
+
if (input === " ") return named("space");
|
|
30
|
+
if (input === "" || CONTROL.test(input)) return void 0;
|
|
31
|
+
return {
|
|
32
|
+
_tag: "Char",
|
|
33
|
+
char: input
|
|
34
|
+
};
|
|
35
|
+
},
|
|
36
|
+
named,
|
|
37
|
+
char: (char) => ({
|
|
38
|
+
_tag: "Char",
|
|
39
|
+
char
|
|
40
|
+
})
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { UiKey };
|
package/ui/UiProvider.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { inkModules } from "./internal/ink.js";
|
|
2
|
+
import { screenContext } from "./internal/ScreenContext.js";
|
|
3
|
+
import { uiProviders } from "./internal/UiProviders.js";
|
|
4
|
+
|
|
5
|
+
//#region src/ui/UiProvider.ts
|
|
6
|
+
/**
|
|
7
|
+
* Provide the kit's context to an Ink tree the kit did not mount, so `useTheme`, `useGlyphs`, `Styled` and
|
|
8
|
+
* `useTerminalSize` work in it.
|
|
9
|
+
*
|
|
10
|
+
* @remarks
|
|
11
|
+
* Take the value from `CliUi.context`, which also loads Ink and React: the provider and the kit's hooks render with
|
|
12
|
+
* the modules the kit loaded. A screen mounted by `CliUi.run` already has this context.
|
|
13
|
+
*
|
|
14
|
+
* With `size`, `useTerminalSize` reads it instead of the stdout Ink draws on, less one column and one row as ever.
|
|
15
|
+
* Give it to Ink's `renderToString`, whose terminal hooks see the process's own stdout rather than the width it lays
|
|
16
|
+
* out at: `renderToString(tree, { columns })` with `size: { columns, rows }` keeps the kit's widgets cut to that
|
|
17
|
+
* width. A tree given a `size` no longer follows the terminal's resizes.
|
|
18
|
+
*
|
|
19
|
+
* Standalone, there is no screen to end: a kit widget's own quit key, such as `Select`'s `q`, does nothing, and an
|
|
20
|
+
* input handler a kit widget registers is called as is, so what it throws escapes as Ink leaves it. Nested inside a `CliUi.run` screen, it overrides only the theme, the glyphs and the size: the screen's
|
|
21
|
+
* cancel and its defect route pass through, so `q` still cancels and a throwing handler is still the screen's defect.
|
|
22
|
+
* Ink's colour level is the host's: `Styled` passes the theme's props, none at colour `none`.
|
|
23
|
+
*
|
|
24
|
+
* @param props - the context, and the tree
|
|
25
|
+
*
|
|
26
|
+
* @public
|
|
27
|
+
*/
|
|
28
|
+
const UiProvider = (props) => {
|
|
29
|
+
const { react } = inkModules();
|
|
30
|
+
const { theme, glyphs, size } = props.value;
|
|
31
|
+
const columns = size?.columns;
|
|
32
|
+
const rows = size?.rows;
|
|
33
|
+
const parent = react.useContext(screenContext());
|
|
34
|
+
const cancel = parent?.cancel;
|
|
35
|
+
const die = parent?.die;
|
|
36
|
+
const neutralize = props.value.neutralizeWorkflowCommands === true || parent?.neutralizeWorkflowCommands === true;
|
|
37
|
+
const value = react.useMemo(() => ({
|
|
38
|
+
theme,
|
|
39
|
+
glyphs,
|
|
40
|
+
...columns === void 0 || rows === void 0 ? {} : { size: {
|
|
41
|
+
columns,
|
|
42
|
+
rows
|
|
43
|
+
} },
|
|
44
|
+
...cancel === void 0 ? {} : { cancel },
|
|
45
|
+
...die === void 0 ? {} : { die },
|
|
46
|
+
...neutralize ? { neutralizeWorkflowCommands: true } : {}
|
|
47
|
+
}), [
|
|
48
|
+
theme,
|
|
49
|
+
glyphs,
|
|
50
|
+
columns,
|
|
51
|
+
rows,
|
|
52
|
+
cancel,
|
|
53
|
+
die,
|
|
54
|
+
neutralize
|
|
55
|
+
]);
|
|
56
|
+
return uiProviders(value, props.children);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
//#endregion
|
|
60
|
+
export { UiProvider };
|
package/ui/UiStreams.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { processStreams } from "./internal/processStreams.js";
|
|
2
|
+
import { Context } from "effect";
|
|
3
|
+
|
|
4
|
+
//#region src/ui/UiStreams.ts
|
|
5
|
+
/**
|
|
6
|
+
* The streams a screen mounts on, the process's own standard streams by default.
|
|
7
|
+
*
|
|
8
|
+
* @remarks
|
|
9
|
+
* A `Context.Reference`, so it never appears in `R`: the default reads the process streams when first used, never
|
|
10
|
+
* at import, and a test provides in-memory streams with `Effect.provideService(UiStreams, streams)`. `./ui` binds
|
|
11
|
+
* Node's process streams.
|
|
12
|
+
*
|
|
13
|
+
* @public
|
|
14
|
+
*/
|
|
15
|
+
var UiStreams = class extends Context.Reference("@effected/cli/ui/UiStreams", { defaultValue: () => processStreams() }) {};
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { UiStreams };
|
package/ui/UiTheme.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { inkModules } from "./internal/ink.js";
|
|
2
|
+
import { screenContext } from "./internal/ScreenContext.js";
|
|
3
|
+
|
|
4
|
+
//#region src/ui/UiTheme.ts
|
|
5
|
+
const OUTSIDE = "@effected/cli/ui: a theme hook was used outside a screen mounted by CliUi.run or a UiProvider";
|
|
6
|
+
const useScreen = () => {
|
|
7
|
+
const screen = inkModules().react.useContext(screenContext());
|
|
8
|
+
if (screen === void 0) throw new Error(OUTSIDE);
|
|
9
|
+
return screen;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* The Ink `Text` props for `style` at `color`.
|
|
13
|
+
*
|
|
14
|
+
* @remarks
|
|
15
|
+
* At `"none"` it gives no styling props at all, not even bold or dim, so a frame is escape-free by construction;
|
|
16
|
+
* Ink's colour level held at 0 is the backstop. A flag set to `false` adds no prop.
|
|
17
|
+
*
|
|
18
|
+
* Omit `color` in an Ink tree the kit did not mount, which has no colour level of its own to pass: every prop is
|
|
19
|
+
* emitted, as at any level but `"none"`, and Ink's own chalk gates what reaches the terminal.
|
|
20
|
+
*
|
|
21
|
+
* @param style - the resolved style
|
|
22
|
+
* @param color - the stream's colour level; omitted, every prop is emitted for Ink's chalk to gate
|
|
23
|
+
*
|
|
24
|
+
* @public
|
|
25
|
+
*/
|
|
26
|
+
const inkProps = (style, color) => color === "none" ? {} : {
|
|
27
|
+
...style.fg === void 0 ? {} : { color: style.fg },
|
|
28
|
+
...style.bold === true ? { bold: true } : {},
|
|
29
|
+
...style.dim === true ? { dimColor: true } : {},
|
|
30
|
+
...style.italic === true ? { italic: true } : {},
|
|
31
|
+
...style.underline === true ? { underline: true } : {}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The theme of the stream the mounted screen draws on.
|
|
35
|
+
*
|
|
36
|
+
* @remarks
|
|
37
|
+
* A React hook: call it from a component rendered inside a `CliUi.run` screen or a `UiProvider`.
|
|
38
|
+
*
|
|
39
|
+
* @public
|
|
40
|
+
*/
|
|
41
|
+
const useTheme = () => useScreen().theme;
|
|
42
|
+
/**
|
|
43
|
+
* The glyph set of the mounted screen, so a component draws Unicode or ASCII glyphs to match the rest of the output.
|
|
44
|
+
*
|
|
45
|
+
* @remarks
|
|
46
|
+
* A React hook: call it from a component rendered inside a `CliUi.run` screen or a `UiProvider`.
|
|
47
|
+
*
|
|
48
|
+
* @public
|
|
49
|
+
*/
|
|
50
|
+
const useGlyphs = () => useScreen().glyphs;
|
|
51
|
+
/**
|
|
52
|
+
* Text painted with a theme token or style, through the mounted screen's theme.
|
|
53
|
+
*
|
|
54
|
+
* @remarks
|
|
55
|
+
* Its children are drawn as given. The kit's widgets sanitise every string they draw from data (escapes removed,
|
|
56
|
+
* line breaks folded) before handing it here; text a consumer passes to `Styled`, or to Ink's own `Text`, is the
|
|
57
|
+
* consumer's to sanitise, with `Fmt.sanitize`. Ink keeps the escape sequences it is handed, so text from data drawn
|
|
58
|
+
* unsanitised can paint colour at colour `none` or plant a hyperlink, and a line break in it adds a row the screen's
|
|
59
|
+
* height budget did not count.
|
|
60
|
+
*
|
|
61
|
+
* @param props - the token or style, and the text
|
|
62
|
+
*
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
const Styled = (props) => {
|
|
66
|
+
const theme = useTheme();
|
|
67
|
+
const { ink, react } = inkModules();
|
|
68
|
+
return react.createElement(ink.Text, inkProps(theme.style(props.token), theme.color), props.children);
|
|
69
|
+
};
|
|
70
|
+
/** A reported size, or `fallback` when it is unknown: absent, or not positive (a pty `script` opens reports 0x0). */
|
|
71
|
+
const known = (reported, fallback) => reported !== void 0 && reported > 0 ? reported : fallback;
|
|
72
|
+
/**
|
|
73
|
+
* The usable terminal size: the stdout Ink draws on, less one column and one row, re-read on every render and when
|
|
74
|
+
* the terminal resizes; or, under a `UiProvider` given a `size`, that size less one column and one row.
|
|
75
|
+
*
|
|
76
|
+
* @remarks
|
|
77
|
+
* A width or height the stream does not report, or reports as 0 (a pty that `script` opens says `0 0`), is unknown and
|
|
78
|
+
* reads as 80 columns by 24 rows, so a screen never lays itself out at width 0. This is not Ink's own fallback, which
|
|
79
|
+
* first asks the process's terminal (`terminal-size`: the tty, `COLUMNS`, `tput`) and only then uses 80x24; the kit
|
|
80
|
+
* reads no `process` here. On a 0x0 pty with `COLUMNS=50`, Ink lays out at 50 while these rows are cut at 79.
|
|
81
|
+
*
|
|
82
|
+
* The override exists for Ink's `renderToString`, whose `useStdout` is the process's own stdout whatever width it
|
|
83
|
+
* lays out at; without it, the kit's widgets would cut their rows to the wrong width there.
|
|
84
|
+
*
|
|
85
|
+
* Never feed `columns` into a `Box`'s `width`. On a resize Ink re-lays out the tree it already has and repaints
|
|
86
|
+
* before React re-renders with the new size, so a width taken from this hook is one paint stale. After a shrink,
|
|
87
|
+
* that stale, wider frame wraps in the narrower terminal and leaves a copy stranded above the live one. For a
|
|
88
|
+
* one-column margin use `marginRight: 1`, which Ink recomputes within its own resize. Text cut to `columns` lags the
|
|
89
|
+
* same paint, so give a long row Ink's `wrap: "truncate-end"` too: on a shrink Ink then clips it rather than letting
|
|
90
|
+
* the terminal wrap it.
|
|
91
|
+
*
|
|
92
|
+
* A React hook: call it from a component rendered inside an Ink tree; it needs no screen, but reads a `UiProvider`'s
|
|
93
|
+
* size when there is one.
|
|
94
|
+
*
|
|
95
|
+
* @public
|
|
96
|
+
*/
|
|
97
|
+
const useTerminalSize = () => {
|
|
98
|
+
const { ink, react } = inkModules();
|
|
99
|
+
const { stdout } = ink.useStdout();
|
|
100
|
+
const override = react.useContext(screenContext())?.size;
|
|
101
|
+
const [, redraw] = react.useReducer((count) => count + 1, 0);
|
|
102
|
+
const followsStdout = override === void 0;
|
|
103
|
+
react.useEffect(() => {
|
|
104
|
+
if (!followsStdout) return void 0;
|
|
105
|
+
stdout.on("resize", redraw);
|
|
106
|
+
return () => {
|
|
107
|
+
stdout.off("resize", redraw);
|
|
108
|
+
};
|
|
109
|
+
}, [stdout, followsStdout]);
|
|
110
|
+
const columns = override?.columns ?? stdout.columns;
|
|
111
|
+
const rows = override?.rows ?? stdout.rows;
|
|
112
|
+
return {
|
|
113
|
+
columns: Math.max(1, known(columns, 80) - 1),
|
|
114
|
+
rows: Math.max(1, known(rows, 24) - 1)
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
//#endregion
|
|
119
|
+
export { Styled, inkProps, useGlyphs, useTerminalSize, useTheme };
|