Уже больше года я создаю CLI-инструменты для двух своих проектов с открытым исходным кодом. Я начал с Ink, так как у меня в команде есть разработчики React, и мы были под впечатлением, что он позволит нам быстро создавать вещи, но это оказалось обманчивым, если не сказать больше.
Мы попробовали Inquirer, Enquirer, Prompts и Ink, и по какой-то причине нет ни одной библиотеки, которая позволила бы вам настраивать материал. Каждая библиотека практически сфокусирована на определенном сценарии, не давая вам возможности двигаться дальше.
Я провел некоторое время, копаясь во внутренностях библиотек, и обнаружил, что ядро библиотеки довольно простое.
Как разработчик библиотеки, я могу справиться с 95% закулисных вещей и позволить пользователю выводить текст так, как он хочет, оставляя пространство для безграничного дизайна CLI.
Вот как вы получаете stdin
, stdout
и readline
const stdin = process.stdin;
const stdout = process.stdout;
const readline = require("readline");
const rl = readline.createInterface({
input: stdin,
escapeCodeTimeout: 50,
});
readline.emitKeypressEvents(stdin, rl);
теперь мы можем прослушивать все события нажатия клавиш
// keystrokes are typed
type Key = {
sequence: string;
name: string | undefined;
ctrl: boolean;
meta: boolean;
shift: boolean;
};
const keypress = (str: string | undefined, key: Key) => {
// handle keypresses
}
stdin.on("keypress", keypress);
// whenever you're done, you just close readline
readline.close()
Теперь для вывода текста мы просто выводим его в stdout
let previousText = "";
stdout.write(clear(previousText, stdout.columns));
stdout.write(string);
previousText = string;
// here's how you clear cli
const strip = (str: string) => {
const pattern = [
"[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/#&.:=?%@~_]*)*)?\u0007)",
"(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-ntqry=><~]))",
].join("|");
const RGX = new RegExp(pattern, "g");
return typeof str === "string" ? str.replace(RGX, "") : str;
};
const stringWidth = (str: string) => [...strip(str)].length;
export const clear = function (prompt: string, perLine: number) {
if (!perLine) return erase.line + cursor.to(0);
let rows = 0;
const lines = prompt.split(/r?n/);
for (let line of lines) {
rows += 1 + Math.floor(Math.max(stringWidth(line) - 1, 0) / perLine);
}
return erase.lines(rows);
};
При создании инструментария без дизайна я все еще стремлюсь предоставить пользователю как можно больше полезностей, поэтому я решил реализовать StateWrappers для специфичных для домена видов Inputs, таких как select
.
вот как будет выглядеть обертка состояния select
. Приведенный ниже вариант предназначен для простого массива строк, он обрабатывает нажатия клавиш up
и down
, отслеживает индекс selected и зацикливает его, когда он выходит за границы:
export class SelectState {
public selectedIdx = 0;
constructor(public readonly items: string[]) {}
consume(str: string | undefined, key: AnyKey): boolean {
if (!key) return false;
if (key.name === "down") {
this.selectedIdx = (this.selectedIdx + 1) % this.items.length;
return true;
}
if (key.name === "up") {
this.selectedIdx -= 1;
this.selectedIdx =
this.selectedIdx < 0 ? this.items.length - 1 : this.selectedIdx;
return true;
}
return false;
}
}
Это API Prompt
, определенный библиотекой.
export abstract class Prompt<RESULT> {
protected terminal: ITerminal | undefined;
protected requestLayout() {
this.terminal!.requestLayout();
}
attach(terminal: ITerminal) {
this.terminal = terminal;
this.onAttach(terminal);
}
detach(terminal: ITerminal) {
this.onDetach(terminal);
this.terminal = undefined;
}
onInput(str: string | undefined, key: AnyKey) {}
abstract result(): RESULT;
abstract onAttach(terminal: ITerminal): void;
abstract onDetach(terminal: ITerminal): void;
abstract render(status: "idle" | "submitted" | "aborted"): string;
}
Теперь все, что вам, как разработчику, нужно сделать, это определить, как отображать элемент select
и не беспокоиться об управлении состоянием и нажатием клавиш, вы просто оставляете это библиотеке и заменяете ее пользовательскими реализациями, когда это необходимо.
export class Select extends Prompt<{ index: number; value: string }> {
private readonly data: SelectState;
constructor(items: string[]) {
super();
this.data = new SelectState(items);
}
onAttach(terminal: ITerminal) {
terminal.toggleCursor("hide");
}
onDetach(terminal: ITerminal) {
terminal.toggleCursor("show");
}
override onInput(str: string | undefined, key: any) {
super.onInput(str, key);
const invlidate = this.data.consume(str, key);
if (invlidate) {
this.requestLayout();
return;
}
}
render(status: "idle" | "submitted" | "aborted"): string {
if (status === "submitted" || status === "aborted") {
return "";
}
let text = "";
this.data.items.forEach((it, idx) => {
text +=
idx === this.data.selectedIdx ? `${color.green("❯ " + it)}` : ` ${it}`;
text += idx != this.data.items.length - 1 ? "n" : "";
});
return text;
}
result() {
return {
index: this.data.selectedIdx,
value: this.data.items[this.data.selectedIdx]!,
};
}
}
Теперь мы просто рендерим и ждем ввода данных пользователем
const result = await render(new Select(["user1", "user2" ...]))
Я потратил немного времени в выходные и опубликовал v0.0.1.
вы можете попробовать — https://www.npmjs.com/package/hanji
Скоро я собираюсь выпустить v0.0.2 с поддержкой CTRL+C и упрощением API.
Вы можете следить за новостями на twitter — https://twitter.com/_alexblokh