@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/Select.js
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
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.
|
|
164
|
+
*
|
|
165
|
+
* @remarks
|
|
166
|
+
* A choice's `detail` is drawn only while that choice is highlighted, as one muted line under the list, so the others'
|
|
167
|
+
* details are not on screen until the cursor reaches them. Enter calls `onSubmit` with the value; `q` cancels the screen with `"escape"`.
|
|
168
|
+
*
|
|
169
|
+
* Single-shot: the choices and the starting choice are read once, when the view mounts, and later changes to them
|
|
170
|
+
* are ignored; after a submit it stays as it is. Render a new view (a new screen) to ask again.
|
|
171
|
+
*
|
|
172
|
+
* @param props - the message, the choices, and where the chosen value goes
|
|
173
|
+
*/
|
|
174
|
+
static View = (props) => {
|
|
175
|
+
const { ink, react } = inkModules();
|
|
176
|
+
const glyphs = useGlyphs();
|
|
177
|
+
const theme = useTheme();
|
|
178
|
+
const { columns } = useTerminalSize();
|
|
179
|
+
const cancel = useScreenCancel();
|
|
180
|
+
const [state, setState] = react.useState(() => init(props.choices, {
|
|
181
|
+
...props.initial === void 0 ? {} : { initial: props.initial },
|
|
182
|
+
...props.height === void 0 ? {} : { height: props.height }
|
|
183
|
+
}));
|
|
184
|
+
const submitted = state.submitted;
|
|
185
|
+
const { onSubmit } = props;
|
|
186
|
+
react.useEffect(() => {
|
|
187
|
+
const choice = state.choices[state.viewport.cursor];
|
|
188
|
+
if (submitted && choice !== void 0) onSubmit(choice.value);
|
|
189
|
+
}, [submitted]);
|
|
190
|
+
useKeys(KEYS, (action) => {
|
|
191
|
+
if (action === "cancel") cancel("escape");
|
|
192
|
+
else setState((current) => step(current, action));
|
|
193
|
+
});
|
|
194
|
+
const rows = props.choices.map((_, index) => ({
|
|
195
|
+
_tag: "Item",
|
|
196
|
+
key: String(index)
|
|
197
|
+
}));
|
|
198
|
+
const blank = " ".repeat(Fmt.width(glyphs.arrow));
|
|
199
|
+
const renderRow = (row, highlighted) => {
|
|
200
|
+
const choice = row._tag === "Item" ? props.choices[Number(row.key)] : void 0;
|
|
201
|
+
const line = `${highlighted ? glyphs.arrow : blank} ${lineText(choice?.label ?? "")}`;
|
|
202
|
+
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 });
|
|
203
|
+
if (highlighted) return react.createElement(Styled, { token: "accent" }, text);
|
|
204
|
+
if (choice?.disabled === true) return react.createElement(Styled, { token: "muted" }, text);
|
|
205
|
+
return react.createElement(ink.Text, null, text);
|
|
206
|
+
};
|
|
207
|
+
const detail = props.choices[state.viewport.cursor]?.detail;
|
|
208
|
+
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, {
|
|
209
|
+
rows,
|
|
210
|
+
state: state.viewport,
|
|
211
|
+
renderRow,
|
|
212
|
+
reserved: RESERVED
|
|
213
|
+
}), detail === void 0 ? null : react.createElement(Styled, { token: "muted" }, Fmt.truncate(lineText(detail), columns, { ellipsis: glyphs.ellipsis })), react.createElement(KeyHelp, { tables: [KEYS] }));
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* A ready-made screen for `CliUi.run`: the select, resolving with the chosen value.
|
|
217
|
+
*
|
|
218
|
+
* @param options - the message, the choices, the starting choice and the list height
|
|
219
|
+
*/
|
|
220
|
+
static screen = (options) => (control) => {
|
|
221
|
+
if (!options.choices.some((choice) => choice.disabled !== true)) throw new Error(NO_ENABLED_CHOICE);
|
|
222
|
+
return inkModules().react.createElement(Select.View, {
|
|
223
|
+
...options,
|
|
224
|
+
onSubmit: control.resolve
|
|
225
|
+
});
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
//#endregion
|
|
230
|
+
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 };
|