@effected/cli 0.10.0 → 0.11.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 +253 -0
- package/CliInteractive.js +71 -0
- package/CliLinks.js +154 -0
- package/CliLog.js +294 -0
- package/CliLogger.js +34 -33
- package/CliMessage.js +83 -0
- package/CliPrompt.js +104 -0
- package/CliRuntime.js +103 -53
- package/CliTest.js +16 -0
- package/CliTheme.js +128 -0
- package/ConfigIssueRenderer.js +14 -33
- package/Doc.js +512 -0
- package/Fmt.js +133 -0
- package/GithubAnnotation.js +40 -0
- package/Glyphs.js +83 -0
- package/NotInteractive.js +42 -0
- package/README.md +129 -131
- package/Render.js +254 -0
- package/SchemaIssueRenderer.js +7 -10
- package/Status.js +163 -0
- package/TestTerminal.js +80 -0
- package/Token.js +69 -0
- package/index.d.ts +2923 -171
- 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 +69 -0
- package/internal/diagnostics.js +32 -0
- package/internal/displayWidth.js +35 -0
- package/internal/failureTarget.js +156 -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 +319 -0
- package/internal/renderGithubLog.js +46 -0
- package/internal/renderMarkdown.js +367 -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 +35 -5
- package/testing.d.ts +88 -2
- package/testing.js +2 -1
- package/ui/CliUi.js +348 -0
- package/ui/CliUiLive.js +399 -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 +226 -0
- package/ui/Tabs.js +202 -0
- package/ui/TextInput.js +250 -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/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 +735 -0
- package/ui/testing/fakeStreams.js +76 -0
- package/ui/testing/terminalModel.js +59 -0
- package/ui-testing.d.ts +446 -0
- package/ui-testing.js +3 -0
- package/ui.d.ts +1648 -0
- package/ui.js +17 -0
package/ui/Select.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
import { inkModules } from "./internal/ink.js";
|
|
3
|
+
import { useScreenCancel } from "./internal/ScreenContext.js";
|
|
4
|
+
import { Styled, useGlyphs, useTerminalSize, useTheme } from "./UiTheme.js";
|
|
5
|
+
import { KeyTable, useKeys } from "./KeyTable.js";
|
|
6
|
+
import { lineText } from "./internal/lineText.js";
|
|
7
|
+
import { KeyHelp } from "./KeyHelp.js";
|
|
8
|
+
import { Viewport } from "./Viewport.js";
|
|
9
|
+
import { Option } from "effect";
|
|
10
|
+
|
|
11
|
+
//#region src/ui/Select.ts
|
|
12
|
+
const enabled = (choices, index) => index >= 0 && index < choices.length && choices[index]?.disabled !== true;
|
|
13
|
+
/** The nearest enabled index from `from` in `direction`, or `undefined`. */
|
|
14
|
+
const nearest = (choices, from, direction) => {
|
|
15
|
+
for (let index = from; index >= 0 && index < choices.length; index += direction) if (enabled(choices, index)) return index;
|
|
16
|
+
};
|
|
17
|
+
/** Move the viewport's cursor to `index`, one step at a time so its window follows. */
|
|
18
|
+
const moveTo = (viewport, index) => {
|
|
19
|
+
let state = viewport;
|
|
20
|
+
while (state.cursor < index) state = Viewport.step(state, "down");
|
|
21
|
+
while (state.cursor > index) state = Viewport.step(state, "up");
|
|
22
|
+
return state;
|
|
23
|
+
};
|
|
24
|
+
const NO_ENABLED_CHOICE = "@effected/cli/ui: Select needs at least one enabled choice";
|
|
25
|
+
const init = (choices, options = {}) => {
|
|
26
|
+
if (!choices.some((choice) => choice.disabled !== true)) throw new Error(NO_ENABLED_CHOICE);
|
|
27
|
+
const height = options.height ?? 10;
|
|
28
|
+
const start = options.initial ?? 0;
|
|
29
|
+
const first = nearest(choices, start, 1) ?? nearest(choices, start, -1) ?? 0;
|
|
30
|
+
return {
|
|
31
|
+
choices,
|
|
32
|
+
viewport: moveTo(Viewport.init(choices.length, height), first),
|
|
33
|
+
submitted: false
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
const step = (state, action) => {
|
|
37
|
+
const { choices, viewport } = state;
|
|
38
|
+
if (action === "cancel") return state;
|
|
39
|
+
if (action === "submit") return enabled(choices, viewport.cursor) ? {
|
|
40
|
+
...state,
|
|
41
|
+
submitted: true
|
|
42
|
+
} : state;
|
|
43
|
+
const last = choices.length - 1;
|
|
44
|
+
const paged = Viewport.step(viewport, action).cursor;
|
|
45
|
+
const target = action === "up" ? nearest(choices, viewport.cursor - 1, -1) : action === "down" ? nearest(choices, viewport.cursor + 1, 1) : action === "home" ? nearest(choices, 0, 1) : action === "end" ? nearest(choices, last, -1) : action === "pageup" ? nearest(choices, paged, -1) ?? nearest(choices, paged, 1) : nearest(choices, paged, 1) ?? nearest(choices, paged, -1);
|
|
46
|
+
return target === void 0 ? state : {
|
|
47
|
+
...state,
|
|
48
|
+
viewport: moveTo(viewport, target)
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
/** ↑/↓ shown; the page, home and end moves bound but hidden, so the help line fits 80 columns. */
|
|
52
|
+
const KEYS = KeyTable.make([
|
|
53
|
+
{
|
|
54
|
+
keys: ["up"],
|
|
55
|
+
action: "up",
|
|
56
|
+
help: "move"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
keys: ["down"],
|
|
60
|
+
action: "down",
|
|
61
|
+
help: "move"
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
keys: ["pageup"],
|
|
65
|
+
action: "pageup",
|
|
66
|
+
help: "page",
|
|
67
|
+
hidden: true
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
keys: ["pagedown"],
|
|
71
|
+
action: "pagedown",
|
|
72
|
+
help: "page",
|
|
73
|
+
hidden: true
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
keys: ["home"],
|
|
77
|
+
action: "home",
|
|
78
|
+
help: "top",
|
|
79
|
+
hidden: true
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
keys: ["end"],
|
|
83
|
+
action: "end",
|
|
84
|
+
help: "bottom",
|
|
85
|
+
hidden: true
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
keys: ["enter"],
|
|
89
|
+
action: "submit",
|
|
90
|
+
help: "choose"
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
keys: [{ char: "q" }],
|
|
94
|
+
action: "cancel",
|
|
95
|
+
help: "cancel"
|
|
96
|
+
}
|
|
97
|
+
]);
|
|
98
|
+
/** The text mark a disabled row carries at colour `none`, where the muted token paints nothing. */
|
|
99
|
+
const DISABLED = " (disabled)";
|
|
100
|
+
/** Lines around the list: the message above, the detail and the help line below. */
|
|
101
|
+
const RESERVED = 3;
|
|
102
|
+
/**
|
|
103
|
+
* A single choice from a list: a pure reducer, its key table, a view, and a ready-made screen.
|
|
104
|
+
*
|
|
105
|
+
* @remarks
|
|
106
|
+
* A select with no enabled choice is a programming error: `init` throws, and `screen` dies, saying so.
|
|
107
|
+
*
|
|
108
|
+
* @example
|
|
109
|
+
* ```ts
|
|
110
|
+
* import { CliUi, Select } from "@effected/cli/ui"
|
|
111
|
+
* import { Effect } from "effect"
|
|
112
|
+
*
|
|
113
|
+
* const pickTarget = Effect.gen(function* () {
|
|
114
|
+
* const target = yield* CliUi.run(
|
|
115
|
+
* Select.screen({
|
|
116
|
+
* message: "Deploy to which environment?",
|
|
117
|
+
* choices: [
|
|
118
|
+
* { label: "staging", value: "staging" },
|
|
119
|
+
* { label: "production", value: "production", detail: "Needs approval" },
|
|
120
|
+
* ],
|
|
121
|
+
* }),
|
|
122
|
+
* )
|
|
123
|
+
* return target
|
|
124
|
+
* })
|
|
125
|
+
* ```
|
|
126
|
+
*
|
|
127
|
+
* @public
|
|
128
|
+
*/
|
|
129
|
+
var Select = class Select {
|
|
130
|
+
constructor() {}
|
|
131
|
+
/**
|
|
132
|
+
* A select over `choices`, on the first enabled choice at or after `initial` (the nearest enabled one before it
|
|
133
|
+
* when none follows).
|
|
134
|
+
*
|
|
135
|
+
* @param choices - the choices
|
|
136
|
+
* @param options - the starting choice and the list height
|
|
137
|
+
*/
|
|
138
|
+
static init = init;
|
|
139
|
+
/**
|
|
140
|
+
* Apply an action: a move lands on the nearest enabled choice, never on a disabled one and never past either
|
|
141
|
+
* end; `"submit"` marks the highlighted choice chosen (a disabled one never is); `"cancel"` changes nothing here,
|
|
142
|
+
* because ending the screen is the view's job.
|
|
143
|
+
*
|
|
144
|
+
* @param state - where the select is
|
|
145
|
+
* @param action - the action
|
|
146
|
+
*/
|
|
147
|
+
static step = step;
|
|
148
|
+
/**
|
|
149
|
+
* The submitted value, or `None` before a submit.
|
|
150
|
+
*
|
|
151
|
+
* @param state - where the select is
|
|
152
|
+
*/
|
|
153
|
+
static chosen = (state) => {
|
|
154
|
+
const choice = state.choices[state.viewport.cursor];
|
|
155
|
+
return state.submitted && choice !== void 0 ? Option.some(choice.value) : Option.none();
|
|
156
|
+
};
|
|
157
|
+
/** The keys: the viewport's moves, enter choose, q cancel. */
|
|
158
|
+
static keys = KEYS;
|
|
159
|
+
/**
|
|
160
|
+
* Draw the select: the message, the list (the highlighted row in the accent token with the arrow glyph, disabled
|
|
161
|
+
* rows muted, and at colour `none` ending in ` (disabled)` instead, every row cut to the width with the glyph set's
|
|
162
|
+
* ellipsis), the highlighted choice's detail, and the
|
|
163
|
+
* key help. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
|
|
164
|
+
*
|
|
165
|
+
* Single-shot: the choices and the starting choice are read once, when the view mounts, and later changes to them
|
|
166
|
+
* are ignored; after a submit it stays as it is. Render a new view (a new screen) to ask again.
|
|
167
|
+
*
|
|
168
|
+
* @param props - the message, the choices, and where the chosen value goes
|
|
169
|
+
*/
|
|
170
|
+
static View = (props) => {
|
|
171
|
+
const { ink, react } = inkModules();
|
|
172
|
+
const glyphs = useGlyphs();
|
|
173
|
+
const theme = useTheme();
|
|
174
|
+
const { columns } = useTerminalSize();
|
|
175
|
+
const cancel = useScreenCancel();
|
|
176
|
+
const [state, setState] = react.useState(() => init(props.choices, {
|
|
177
|
+
...props.initial === void 0 ? {} : { initial: props.initial },
|
|
178
|
+
...props.height === void 0 ? {} : { height: props.height }
|
|
179
|
+
}));
|
|
180
|
+
const submitted = state.submitted;
|
|
181
|
+
const { onSubmit } = props;
|
|
182
|
+
react.useEffect(() => {
|
|
183
|
+
const choice = state.choices[state.viewport.cursor];
|
|
184
|
+
if (submitted && choice !== void 0) onSubmit(choice.value);
|
|
185
|
+
}, [submitted]);
|
|
186
|
+
useKeys(KEYS, (action) => {
|
|
187
|
+
if (action === "cancel") cancel("escape");
|
|
188
|
+
else setState((current) => step(current, action));
|
|
189
|
+
});
|
|
190
|
+
const rows = props.choices.map((_, index) => ({
|
|
191
|
+
_tag: "Item",
|
|
192
|
+
key: String(index)
|
|
193
|
+
}));
|
|
194
|
+
const blank = " ".repeat(Fmt.width(glyphs.arrow));
|
|
195
|
+
const renderRow = (row, highlighted) => {
|
|
196
|
+
const choice = row._tag === "Item" ? props.choices[Number(row.key)] : void 0;
|
|
197
|
+
const line = `${highlighted ? glyphs.arrow : blank} ${lineText(choice?.label ?? "")}`;
|
|
198
|
+
const text = choice?.disabled === true && theme.color === "none" ? `${Fmt.truncate(line, Math.max(0, columns - Fmt.width(DISABLED)), { ellipsis: glyphs.ellipsis })}${DISABLED}` : Fmt.truncate(line, columns, { ellipsis: glyphs.ellipsis });
|
|
199
|
+
if (highlighted) return react.createElement(Styled, { token: "accent" }, text);
|
|
200
|
+
if (choice?.disabled === true) return react.createElement(Styled, { token: "muted" }, text);
|
|
201
|
+
return react.createElement(ink.Text, null, text);
|
|
202
|
+
};
|
|
203
|
+
const detail = props.choices[state.viewport.cursor]?.detail;
|
|
204
|
+
return react.createElement(ink.Box, { flexDirection: "column" }, react.createElement(Styled, { token: "emphasis" }, Fmt.truncate(lineText(props.message), columns, { ellipsis: glyphs.ellipsis })), react.createElement(Viewport.View, {
|
|
205
|
+
rows,
|
|
206
|
+
state: state.viewport,
|
|
207
|
+
renderRow,
|
|
208
|
+
reserved: RESERVED
|
|
209
|
+
}), detail === void 0 ? null : react.createElement(Styled, { token: "muted" }, Fmt.truncate(lineText(detail), columns, { ellipsis: glyphs.ellipsis })), react.createElement(KeyHelp, { tables: [KEYS] }));
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* A ready-made screen for `CliUi.run`: the select, resolving with the chosen value.
|
|
213
|
+
*
|
|
214
|
+
* @param options - the message, the choices, the starting choice and the list height
|
|
215
|
+
*/
|
|
216
|
+
static screen = (options) => (control) => {
|
|
217
|
+
if (!options.choices.some((choice) => choice.disabled !== true)) throw new Error(NO_ENABLED_CHOICE);
|
|
218
|
+
return inkModules().react.createElement(Select.View, {
|
|
219
|
+
...options,
|
|
220
|
+
onSubmit: control.resolve
|
|
221
|
+
});
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
//#endregion
|
|
226
|
+
export { Select };
|
package/ui/Tabs.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { Fmt } from "../Fmt.js";
|
|
2
|
+
import { inkModules } from "./internal/ink.js";
|
|
3
|
+
import { Styled, useGlyphs, useTerminalSize, useTheme } from "./UiTheme.js";
|
|
4
|
+
import { KeyTable, useKeys } from "./KeyTable.js";
|
|
5
|
+
import { lineText } from "./internal/lineText.js";
|
|
6
|
+
|
|
7
|
+
//#region src/ui/Tabs.ts
|
|
8
|
+
const step = (index, count, action) => {
|
|
9
|
+
if (count <= 0) return index;
|
|
10
|
+
if (action === "prev") return (index - 1 + count) % count;
|
|
11
|
+
if (action === "next") return (index + 1) % count;
|
|
12
|
+
return action.jump >= 0 && action.jump < count ? action.jump : index;
|
|
13
|
+
};
|
|
14
|
+
/** Plain digits jump: 1 is the first tab, 9 the ninth, 0 the tenth. Bound, but left out of the help line. */
|
|
15
|
+
const DIGITS = [
|
|
16
|
+
"1",
|
|
17
|
+
"2",
|
|
18
|
+
"3",
|
|
19
|
+
"4",
|
|
20
|
+
"5",
|
|
21
|
+
"6",
|
|
22
|
+
"7",
|
|
23
|
+
"8",
|
|
24
|
+
"9",
|
|
25
|
+
"0"
|
|
26
|
+
].map((digit, index) => ({
|
|
27
|
+
keys: [{ char: digit }],
|
|
28
|
+
action: { jump: index },
|
|
29
|
+
help: "jump",
|
|
30
|
+
hidden: true
|
|
31
|
+
}));
|
|
32
|
+
const table = (prev, next) => KeyTable.make([
|
|
33
|
+
{
|
|
34
|
+
keys: [prev],
|
|
35
|
+
action: "prev",
|
|
36
|
+
help: "switch"
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
keys: [next],
|
|
40
|
+
action: "next",
|
|
41
|
+
help: "switch"
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
keys: ["tab"],
|
|
45
|
+
action: "next",
|
|
46
|
+
help: "switch"
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
keys: ["shift+tab"],
|
|
50
|
+
action: "prev",
|
|
51
|
+
help: "switch"
|
|
52
|
+
},
|
|
53
|
+
...DIGITS
|
|
54
|
+
]);
|
|
55
|
+
const ROW_KEYS = table("left", "right");
|
|
56
|
+
const COLUMN_KEYS = table("up", "down");
|
|
57
|
+
/** The tabs from `from` to `to` that fit `width` around `active`, widening right then left in turn. */
|
|
58
|
+
const fitAround = (widths, active, gap, width) => {
|
|
59
|
+
let from = active;
|
|
60
|
+
let to = active;
|
|
61
|
+
let used = widths[active] ?? 0;
|
|
62
|
+
let grew = true;
|
|
63
|
+
while (grew) {
|
|
64
|
+
grew = false;
|
|
65
|
+
const right = widths[to + 1];
|
|
66
|
+
if (right !== void 0 && used + gap + right <= width) {
|
|
67
|
+
to++;
|
|
68
|
+
used += gap + right;
|
|
69
|
+
grew = true;
|
|
70
|
+
}
|
|
71
|
+
const left = widths[from - 1];
|
|
72
|
+
if (left !== void 0 && used + gap + left <= width) {
|
|
73
|
+
from--;
|
|
74
|
+
used += gap + left;
|
|
75
|
+
grew = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
from,
|
|
80
|
+
to
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* A row (or column) of tabs, for a consumer's own screen.
|
|
85
|
+
*
|
|
86
|
+
* @remarks
|
|
87
|
+
* Not a screen: render `Tabs.View` inside a consumer's own screen. Its keys come from one `useKeys`, so there is a
|
|
88
|
+
* single input reader. Tab and Shift-Tab cycle whenever the tabs are focused, and plain digits jump, so a screen that
|
|
89
|
+
* hosts Tabs beside another widget reading Tab, arrows or digits (a `TextInput`, say) must decide who has the keys:
|
|
90
|
+
* pass `isFocused: false` to the Tabs while the other widget is being typed into. When unfocused, Tabs reads no key
|
|
91
|
+
* and is drawn muted.
|
|
92
|
+
*
|
|
93
|
+
* @public
|
|
94
|
+
*/
|
|
95
|
+
var Tabs = class {
|
|
96
|
+
constructor() {}
|
|
97
|
+
/**
|
|
98
|
+
* The tab an action lands on: `"prev"` and `"next"` wrap at both ends; `{ jump }` moves to that index and does
|
|
99
|
+
* nothing when it is out of range.
|
|
100
|
+
*
|
|
101
|
+
* @param index - the active tab
|
|
102
|
+
* @param count - how many tabs there are
|
|
103
|
+
* @param action - the action
|
|
104
|
+
*/
|
|
105
|
+
static step = step;
|
|
106
|
+
/** The keys of a row: `←`/`→` and Tab/Shift-Tab switch, plain digits 1–9 jump, 0 is the tenth tab. */
|
|
107
|
+
static keys = ROW_KEYS;
|
|
108
|
+
/** The keys of a column: `↑`/`↓` in place of `←`/`→`. */
|
|
109
|
+
static columnKeys = COLUMN_KEYS;
|
|
110
|
+
/**
|
|
111
|
+
* Draw the tabs: the active one in the accent token, bold and underlined; the others plain; every tab muted while
|
|
112
|
+
* unfocused. At colour `"none"`, where all of that vanishes, the active tab is bracketed, `[Alpha]`, and the others
|
|
113
|
+
* padded a space each side. A row wider than the terminal shows the tabs that fit around the active one, with the
|
|
114
|
+
* glyph set's ellipsis at a cut edge, so it never wraps.
|
|
115
|
+
*
|
|
116
|
+
* @remarks
|
|
117
|
+
* Controlled when `value` is given: a key calls `onChange` and the active tab moves only when `value` does.
|
|
118
|
+
* Uncontrolled otherwise, starting at `defaultValue` or the first tab. Either way `onChange` fires once on mount
|
|
119
|
+
* with the starting tab.
|
|
120
|
+
*
|
|
121
|
+
* @param props - the tabs and how they behave
|
|
122
|
+
*/
|
|
123
|
+
static View = (props) => {
|
|
124
|
+
const { ink, react } = inkModules();
|
|
125
|
+
const glyphs = useGlyphs();
|
|
126
|
+
const theme = useTheme();
|
|
127
|
+
const { columns } = useTerminalSize();
|
|
128
|
+
const indexOf = (name) => name === void 0 ? -1 : props.tabs.findIndex((tab) => tab.name === name);
|
|
129
|
+
const [own, setOwn] = react.useState(() => Math.max(0, indexOf(props.defaultValue)));
|
|
130
|
+
const controlled = props.value !== void 0;
|
|
131
|
+
const index = controlled ? Math.max(0, indexOf(props.value)) : own;
|
|
132
|
+
const focused = props.isFocused ?? true;
|
|
133
|
+
const column = props.direction === "column";
|
|
134
|
+
const { onChange } = props;
|
|
135
|
+
const at = react.useRef(index);
|
|
136
|
+
at.current = index;
|
|
137
|
+
const drawn = react.useRef(index);
|
|
138
|
+
drawn.current = index;
|
|
139
|
+
const announced = react.useRef(false);
|
|
140
|
+
react.useEffect(() => {
|
|
141
|
+
if (announced.current) return;
|
|
142
|
+
announced.current = true;
|
|
143
|
+
const first = props.tabs[index];
|
|
144
|
+
if (first !== void 0) onChange?.(first.name, index);
|
|
145
|
+
}, []);
|
|
146
|
+
useKeys(column ? COLUMN_KEYS : ROW_KEYS, (action) => {
|
|
147
|
+
const from = at.current;
|
|
148
|
+
const next = step(from, props.tabs.length, action);
|
|
149
|
+
const tab = props.tabs[next];
|
|
150
|
+
if (next === from || tab === void 0) return;
|
|
151
|
+
at.current = next;
|
|
152
|
+
if (controlled) queueMicrotask(() => {
|
|
153
|
+
at.current = drawn.current;
|
|
154
|
+
});
|
|
155
|
+
else setOwn(next);
|
|
156
|
+
onChange?.(tab.name, next);
|
|
157
|
+
}, { isActive: focused });
|
|
158
|
+
const labelOf = (position) => `${props.showIndex === true ? `${position + 1}. ` : ""}${lineText(props.tabs[position]?.label ?? "")}`;
|
|
159
|
+
const plain = theme.color === "none";
|
|
160
|
+
const marks = plain ? 2 : 0;
|
|
161
|
+
const marked = (position, text) => plain ? position === index ? `[${text}]` : ` ${text} ` : text;
|
|
162
|
+
const draw = (position, text) => {
|
|
163
|
+
if (!focused) return react.createElement(Styled, {
|
|
164
|
+
key: position,
|
|
165
|
+
token: position === index ? {
|
|
166
|
+
...theme.style("muted"),
|
|
167
|
+
underline: true
|
|
168
|
+
} : "muted"
|
|
169
|
+
}, text);
|
|
170
|
+
return position === index ? react.createElement(Styled, {
|
|
171
|
+
key: position,
|
|
172
|
+
token: {
|
|
173
|
+
...theme.style("accent"),
|
|
174
|
+
bold: true,
|
|
175
|
+
underline: true
|
|
176
|
+
}
|
|
177
|
+
}, text) : react.createElement(ink.Text, { key: position }, text);
|
|
178
|
+
};
|
|
179
|
+
if (column) return react.createElement(ink.Box, { flexDirection: "column" }, ...props.tabs.map((_, position) => react.createElement(ink.Box, { key: position }, draw(position, marked(position, Fmt.truncate(labelOf(position), Math.max(1, columns - marks), { ellipsis: glyphs.ellipsis }))))));
|
|
180
|
+
const separator = lineText(props.separator ?? (glyphs.kind === "unicode" ? " │ " : " | "));
|
|
181
|
+
const labels = props.tabs.map((_, position) => marked(position, labelOf(position)));
|
|
182
|
+
const mark = Fmt.width(glyphs.ellipsis);
|
|
183
|
+
const { from, to } = labels.reduce((sum, label) => sum + Fmt.width(label), 0) + Fmt.width(separator) * (labels.length - 1) <= columns ? {
|
|
184
|
+
from: 0,
|
|
185
|
+
to: labels.length - 1
|
|
186
|
+
} : fitAround(labels.map((label) => Fmt.width(label)), index, Fmt.width(separator), Math.max(1, columns - 2 * mark));
|
|
187
|
+
const children = [];
|
|
188
|
+
if (from > 0) children.push(glyphs.ellipsis);
|
|
189
|
+
for (let position = from; position <= to; position++) {
|
|
190
|
+
if (position > from) children.push(separator);
|
|
191
|
+
const room = Math.max(1, columns - (from > 0 ? mark : 0) - (to < labels.length - 1 ? mark : 0));
|
|
192
|
+
const label = labels[position] ?? "";
|
|
193
|
+
const cut = () => marked(position, Fmt.truncate(labelOf(position), Math.max(1, room - marks), { ellipsis: glyphs.ellipsis }));
|
|
194
|
+
children.push(draw(position, from === to && Fmt.width(label) > room ? cut() : label));
|
|
195
|
+
}
|
|
196
|
+
if (to < labels.length - 1) children.push(glyphs.ellipsis);
|
|
197
|
+
return react.createElement(ink.Text, { wrap: "truncate-end" }, ...children);
|
|
198
|
+
};
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
//#endregion
|
|
202
|
+
export { Tabs };
|
package/ui/TextInput.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
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 isHigh = (code) => code >= 55296 && code <= 56319;
|
|
20
|
+
const isLow = (code) => code >= 56320 && code <= 57343;
|
|
21
|
+
/** The code-point boundary before `at`: one code unit back, two when that would land inside a surrogate pair. */
|
|
22
|
+
const previous = (value, at) => at >= 2 && isLow(value.charCodeAt(at - 1)) && isHigh(value.charCodeAt(at - 2)) ? at - 2 : Math.max(0, at - 1);
|
|
23
|
+
/** The code-point boundary after `at`. */
|
|
24
|
+
const following = (value, at) => at + 1 < value.length && isHigh(value.charCodeAt(at)) && isLow(value.charCodeAt(at + 1)) ? at + 2 : Math.min(value.length, at + 1);
|
|
25
|
+
const insert = (state, text) => ({
|
|
26
|
+
value: state.value.slice(0, state.cursor) + text + state.value.slice(state.cursor),
|
|
27
|
+
cursor: state.cursor + text.length,
|
|
28
|
+
submitted: false
|
|
29
|
+
});
|
|
30
|
+
const CONTROLS = /([\u0000-\u001f\u007f])/;
|
|
31
|
+
const PASTE_CONTROLS = /[\u0000-\u001f\u007f]/g;
|
|
32
|
+
/**
|
|
33
|
+
* The keys one Ink input holds. Text read in one go (a fast typist, a paste) reaches `useInput` as one string, controls
|
|
34
|
+
* and all, so it is split at its control characters: the printable runs are typed whole, `\r` is enter (and ends the
|
|
35
|
+
* input: text after a submit is not typed into a submitted field), a backspace byte is backspace, a line feed is a
|
|
36
|
+
* space (a pasted line break separates words in a one-line field), and a tab or any other control is dropped. A named
|
|
37
|
+
* key, or a Ctrl or Meta combination, is the one key Ink reported.
|
|
38
|
+
*/
|
|
39
|
+
const typedKeys = (input, key) => {
|
|
40
|
+
const single = UiKey.fromInk(input, key);
|
|
41
|
+
if (single?._tag === "Named" || key.ctrl || key.meta || !CONTROLS.test(input) || [...input].length < 2) return single === void 0 ? [] : [single];
|
|
42
|
+
const keys = [];
|
|
43
|
+
for (const piece of input.split(CONTROLS)) {
|
|
44
|
+
if (piece === "") continue;
|
|
45
|
+
if (piece === "\r") {
|
|
46
|
+
keys.push(UiKey.named("enter"));
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (piece === "" || piece === "\b") keys.push(UiKey.named("backspace"));
|
|
50
|
+
else if (piece === "\n") keys.push(UiKey.char(" "));
|
|
51
|
+
else if (!CONTROLS.test(piece)) keys.push(UiKey.char(piece));
|
|
52
|
+
}
|
|
53
|
+
return keys;
|
|
54
|
+
};
|
|
55
|
+
const step = (state, key) => {
|
|
56
|
+
if (key._tag === "Char") return insert(state, key.char);
|
|
57
|
+
const { value, cursor } = state;
|
|
58
|
+
switch (key.name) {
|
|
59
|
+
case "space": return insert(state, " ");
|
|
60
|
+
case "backspace": {
|
|
61
|
+
if (cursor === 0) return state;
|
|
62
|
+
const from = previous(value, cursor);
|
|
63
|
+
return {
|
|
64
|
+
value: value.slice(0, from) + value.slice(cursor),
|
|
65
|
+
cursor: from,
|
|
66
|
+
submitted: false
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
case "delete": return cursor === value.length ? state : {
|
|
70
|
+
value: value.slice(0, cursor) + value.slice(following(value, cursor)),
|
|
71
|
+
cursor,
|
|
72
|
+
submitted: false
|
|
73
|
+
};
|
|
74
|
+
case "left": return {
|
|
75
|
+
...state,
|
|
76
|
+
cursor: previous(value, cursor),
|
|
77
|
+
submitted: false
|
|
78
|
+
};
|
|
79
|
+
case "right": return {
|
|
80
|
+
...state,
|
|
81
|
+
cursor: following(value, cursor),
|
|
82
|
+
submitted: false
|
|
83
|
+
};
|
|
84
|
+
case "home": return {
|
|
85
|
+
...state,
|
|
86
|
+
cursor: 0,
|
|
87
|
+
submitted: false
|
|
88
|
+
};
|
|
89
|
+
case "end": return {
|
|
90
|
+
...state,
|
|
91
|
+
cursor: value.length,
|
|
92
|
+
submitted: false
|
|
93
|
+
};
|
|
94
|
+
case "enter": return {
|
|
95
|
+
...state,
|
|
96
|
+
submitted: true
|
|
97
|
+
};
|
|
98
|
+
default: return state;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
/** Take code points from the end of `text` while they fit in `width` cells. */
|
|
102
|
+
const tail = (text, width) => {
|
|
103
|
+
const points = [...text];
|
|
104
|
+
let out = "";
|
|
105
|
+
let used = 0;
|
|
106
|
+
for (let index = points.length - 1; index >= 0; index--) {
|
|
107
|
+
const point = points[index] ?? "";
|
|
108
|
+
const cells = Fmt.width(point);
|
|
109
|
+
if (used + cells > width) break;
|
|
110
|
+
out = point + out;
|
|
111
|
+
used += cells;
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
/** Take code points from the start of `text` while they fit in `width` cells. */
|
|
116
|
+
const head = (text, width) => {
|
|
117
|
+
let out = "";
|
|
118
|
+
let used = 0;
|
|
119
|
+
for (const point of text) {
|
|
120
|
+
const cells = Fmt.width(point);
|
|
121
|
+
if (used + cells > width) break;
|
|
122
|
+
out += point;
|
|
123
|
+
used += cells;
|
|
124
|
+
}
|
|
125
|
+
return out;
|
|
126
|
+
};
|
|
127
|
+
/**
|
|
128
|
+
* The text either side of the cursor, scrolled so the line fits `width` cells and the cursor stays in view: a cut
|
|
129
|
+
* edge is marked with the ellipsis, the text after the cursor keeps up to a third of the room, and the text before
|
|
130
|
+
* it the rest.
|
|
131
|
+
*/
|
|
132
|
+
const windowAround = (before, after, width, ellipsis) => {
|
|
133
|
+
if (Fmt.width(before) + Fmt.width(after) <= width) return [before, after];
|
|
134
|
+
const mark = Fmt.width(ellipsis);
|
|
135
|
+
const beforeRoom = width - Math.min(Fmt.width(after), Math.floor(width / 3));
|
|
136
|
+
const shownBefore = Fmt.width(before) <= beforeRoom ? before : beforeRoom > mark ? `${ellipsis}${tail(before, beforeRoom - mark)}` : tail(before, Math.max(0, beforeRoom));
|
|
137
|
+
const room = Math.max(0, width - Fmt.width(shownBefore));
|
|
138
|
+
return [shownBefore, Fmt.width(after) <= room ? after : room > mark ? `${head(after, room - mark)}${ellipsis}` : head(after, room)];
|
|
139
|
+
};
|
|
140
|
+
/** Shown in the help line only; the input reads every key itself. */
|
|
141
|
+
const HELP = KeyTable.make([{
|
|
142
|
+
keys: ["enter"],
|
|
143
|
+
action: "submit",
|
|
144
|
+
help: "submit"
|
|
145
|
+
}]);
|
|
146
|
+
/**
|
|
147
|
+
* One line of text: a pure reducer, a view and a ready-made screen.
|
|
148
|
+
*
|
|
149
|
+
* @remarks
|
|
150
|
+
* Every typed character is text, `q` included: the input binds no letter, so Esc and Ctrl-C are the screen's root
|
|
151
|
+
* keys and still cancel with `"escape"` and `"interrupt"`.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```ts
|
|
155
|
+
* import { CliUi, TextInput } from "@effected/cli/ui"
|
|
156
|
+
* import { Effect } from "effect"
|
|
157
|
+
*
|
|
158
|
+
* const askName = Effect.gen(function* () {
|
|
159
|
+
* const name = yield* CliUi.run(
|
|
160
|
+
* TextInput.screen({
|
|
161
|
+
* message: "Package name?",
|
|
162
|
+
* placeholder: "my-package",
|
|
163
|
+
* validate: (value) => (value.trim() === "" ? "A name is required" : undefined),
|
|
164
|
+
* }),
|
|
165
|
+
* )
|
|
166
|
+
* return name
|
|
167
|
+
* })
|
|
168
|
+
* ```
|
|
169
|
+
*
|
|
170
|
+
* @public
|
|
171
|
+
*/
|
|
172
|
+
var TextInput = class TextInput {
|
|
173
|
+
constructor() {}
|
|
174
|
+
/**
|
|
175
|
+
* An input holding `initial`, the cursor after it.
|
|
176
|
+
*
|
|
177
|
+
* @param options - the starting text
|
|
178
|
+
*/
|
|
179
|
+
static init = init;
|
|
180
|
+
/**
|
|
181
|
+
* Apply a key: a typed character (any, `q` included) or space is inserted at the cursor; backspace and delete
|
|
182
|
+
* remove around it; left, right, home and end move it, clamped to the text; enter marks it submitted. Every other
|
|
183
|
+
* key changes nothing.
|
|
184
|
+
*
|
|
185
|
+
* @param state - where the input is
|
|
186
|
+
* @param key - the key pressed
|
|
187
|
+
*/
|
|
188
|
+
static step = step;
|
|
189
|
+
/**
|
|
190
|
+
* Draw the input: the message, the value with the cursor shown as `▏` (`|` under ASCII glyphs, so it stays visible
|
|
191
|
+
* without colour), the placeholder while empty, a validation message in the error token, and the key help. Enter
|
|
192
|
+
* submits when `validate` passes; otherwise its message is shown until the next key other than enter, or a paste.
|
|
193
|
+
*
|
|
194
|
+
* @remarks
|
|
195
|
+
* Text read in one go (a fast typist) is typed as it reads: printable runs are inserted whole, a return submits
|
|
196
|
+
* what came before it (anything after it is dropped), a backspace byte deletes, a line feed becomes a space, and a
|
|
197
|
+
* tab or other control character is dropped. A bracketed paste is inserted as text, its line breaks as spaces, and
|
|
198
|
+
* never submits.
|
|
199
|
+
*
|
|
200
|
+
* @param props - the message, the starting text, the placeholder, the validator and where the value goes
|
|
201
|
+
*/
|
|
202
|
+
static View = (props) => {
|
|
203
|
+
const { ink, react } = inkModules();
|
|
204
|
+
const glyphs = useGlyphs();
|
|
205
|
+
const { columns } = useTerminalSize();
|
|
206
|
+
const [state, setState] = react.useState(() => init(props.initial === void 0 ? {} : { initial: props.initial }));
|
|
207
|
+
const [error, setError] = react.useState(void 0);
|
|
208
|
+
const { validate, onSubmit } = props;
|
|
209
|
+
react.useEffect(() => {
|
|
210
|
+
if (!state.submitted) return;
|
|
211
|
+
const problem = validate?.(state.value);
|
|
212
|
+
if (problem === void 0) onSubmit(state.value);
|
|
213
|
+
else {
|
|
214
|
+
setError(problem);
|
|
215
|
+
setState((current) => ({
|
|
216
|
+
...current,
|
|
217
|
+
submitted: false
|
|
218
|
+
}));
|
|
219
|
+
}
|
|
220
|
+
}, [state.submitted]);
|
|
221
|
+
const guard = useScreenGuard();
|
|
222
|
+
ink.usePaste(guard((text) => {
|
|
223
|
+
const typed = text.replace(/\r\n|\r|\n/g, " ").replace(PASTE_CONTROLS, "");
|
|
224
|
+
if (typed === "") return;
|
|
225
|
+
setError(void 0);
|
|
226
|
+
setState((current) => step(current, UiKey.char(typed)));
|
|
227
|
+
}));
|
|
228
|
+
ink.useInput(guard((input, key) => {
|
|
229
|
+
const keys = typedKeys(input, key);
|
|
230
|
+
if (keys.some((pressed) => pressed._tag === "Named" && (pressed.name === "escape" || pressed.name === "ctrl+c"))) return;
|
|
231
|
+
if (keys.some((pressed) => !(pressed._tag === "Named" && pressed.name === "enter"))) setError(void 0);
|
|
232
|
+
for (const pressed of keys) setState((current) => step(current, pressed));
|
|
233
|
+
}));
|
|
234
|
+
const cursorGlyph = glyphs.kind === "unicode" ? "▏" : "|";
|
|
235
|
+
const [before, after] = windowAround(state.value.slice(0, state.cursor), state.value.slice(state.cursor), columns - Fmt.width(cursorGlyph), glyphs.ellipsis);
|
|
236
|
+
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] }));
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* A ready-made screen for `CliUi.run`: the input, resolving with the submitted text.
|
|
240
|
+
*
|
|
241
|
+
* @param options - the message, the starting text, the placeholder and the validator
|
|
242
|
+
*/
|
|
243
|
+
static screen = (options) => (control) => inkModules().react.createElement(TextInput.View, {
|
|
244
|
+
...options,
|
|
245
|
+
onSubmit: control.resolve
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
//#endregion
|
|
250
|
+
export { TextInput };
|