245 lines
4.6 KiB
JavaScript
245 lines
4.6 KiB
JavaScript
import process from 'node:process';
|
||
import {stripVTControlCharacters} from 'node:util';
|
||
import yoctocolors from 'yoctocolors';
|
||
|
||
const isUnicodeSupported = process.platform !== 'win32'
|
||
|| Boolean(process.env.WT_SESSION) // Windows Terminal
|
||
|| process.env.TERM_PROGRAM === 'vscode';
|
||
|
||
const isInteractive = stream => Boolean(
|
||
stream.isTTY
|
||
&& process.env.TERM !== 'dumb'
|
||
&& !('CI' in process.env),
|
||
);
|
||
|
||
const infoSymbol = yoctocolors.blue(isUnicodeSupported ? 'ℹ' : 'i');
|
||
const successSymbol = yoctocolors.green(isUnicodeSupported ? '✔' : '√');
|
||
const warningSymbol = yoctocolors.yellow(isUnicodeSupported ? '⚠' : '‼');
|
||
const errorSymbol = yoctocolors.red(isUnicodeSupported ? '✖' : '×');
|
||
|
||
const defaultSpinner = {
|
||
frames: isUnicodeSupported
|
||
? [
|
||
'⠋',
|
||
'⠙',
|
||
'⠹',
|
||
'⠸',
|
||
'⠼',
|
||
'⠴',
|
||
'⠦',
|
||
'⠧',
|
||
'⠇',
|
||
'⠏',
|
||
]
|
||
: [
|
||
'-',
|
||
'\\',
|
||
'|',
|
||
'/',
|
||
],
|
||
interval: 80,
|
||
};
|
||
|
||
class YoctoSpinner {
|
||
#frames;
|
||
#interval;
|
||
#currentFrame = -1;
|
||
#timer;
|
||
#text;
|
||
#stream;
|
||
#color;
|
||
#lines = 0;
|
||
#exitHandlerBound;
|
||
#isInteractive;
|
||
#lastSpinnerFrameTime = 0;
|
||
|
||
constructor(options = {}) {
|
||
const spinner = options.spinner ?? defaultSpinner;
|
||
this.#frames = spinner.frames;
|
||
this.#interval = spinner.interval;
|
||
this.#text = options.text ?? '';
|
||
this.#stream = options.stream ?? process.stderr;
|
||
this.#color = options.color ?? 'cyan';
|
||
this.#isInteractive = isInteractive(this.#stream);
|
||
this.#exitHandlerBound = this.#exitHandler.bind(this);
|
||
}
|
||
|
||
start(text) {
|
||
if (text) {
|
||
this.#text = text;
|
||
}
|
||
|
||
if (this.isSpinning) {
|
||
return this;
|
||
}
|
||
|
||
this.#hideCursor();
|
||
this.#render();
|
||
this.#subscribeToProcessEvents();
|
||
|
||
this.#timer = setInterval(() => {
|
||
this.#render();
|
||
}, this.#interval);
|
||
|
||
return this;
|
||
}
|
||
|
||
stop(finalText) {
|
||
if (!this.isSpinning) {
|
||
return this;
|
||
}
|
||
|
||
clearInterval(this.#timer);
|
||
this.#timer = undefined;
|
||
this.#showCursor();
|
||
this.clear();
|
||
this.#unsubscribeFromProcessEvents();
|
||
|
||
if (finalText) {
|
||
this.#stream.write(`${finalText}\n`);
|
||
}
|
||
|
||
return this;
|
||
}
|
||
|
||
#symbolStop(symbol, text) {
|
||
return this.stop(`${symbol} ${text ?? this.#text}`);
|
||
}
|
||
|
||
success(text) {
|
||
return this.#symbolStop(successSymbol, text);
|
||
}
|
||
|
||
error(text) {
|
||
return this.#symbolStop(errorSymbol, text);
|
||
}
|
||
|
||
warning(text) {
|
||
return this.#symbolStop(warningSymbol, text);
|
||
}
|
||
|
||
info(text) {
|
||
return this.#symbolStop(infoSymbol, text);
|
||
}
|
||
|
||
get isSpinning() {
|
||
return this.#timer !== undefined;
|
||
}
|
||
|
||
get text() {
|
||
return this.#text;
|
||
}
|
||
|
||
set text(value) {
|
||
this.#text = value ?? '';
|
||
this.#render();
|
||
}
|
||
|
||
get color() {
|
||
return this.#color;
|
||
}
|
||
|
||
set color(value) {
|
||
this.#color = value;
|
||
this.#render();
|
||
}
|
||
|
||
clear() {
|
||
if (!this.#isInteractive) {
|
||
return this;
|
||
}
|
||
|
||
this.#stream.cursorTo(0);
|
||
|
||
for (let index = 0; index < this.#lines; index++) {
|
||
if (index > 0) {
|
||
this.#stream.moveCursor(0, -1);
|
||
}
|
||
|
||
this.#stream.clearLine(1);
|
||
}
|
||
|
||
this.#lines = 0;
|
||
|
||
return this;
|
||
}
|
||
|
||
#render() {
|
||
// Ensure we only update the spinner frame at the wanted interval,
|
||
// even if the frame method is called more often.
|
||
const now = Date.now();
|
||
if (this.#currentFrame === -1 || now - this.#lastSpinnerFrameTime >= this.#interval) {
|
||
this.#currentFrame = ++this.#currentFrame % this.#frames.length;
|
||
this.#lastSpinnerFrameTime = now;
|
||
}
|
||
|
||
const applyColor = yoctocolors[this.#color] ?? yoctocolors.cyan;
|
||
const frame = this.#frames[this.#currentFrame];
|
||
let string = `${applyColor(frame)} ${this.#text}`;
|
||
|
||
if (!this.#isInteractive) {
|
||
string += '\n';
|
||
}
|
||
|
||
this.clear();
|
||
this.#write(string);
|
||
|
||
if (this.#isInteractive) {
|
||
this.#lines = this.#lineCount(string);
|
||
}
|
||
}
|
||
|
||
#write(text) {
|
||
this.#stream.write(text);
|
||
}
|
||
|
||
#lineCount(text) {
|
||
const width = this.#stream.columns ?? 80;
|
||
const lines = stripVTControlCharacters(text).split('\n');
|
||
|
||
let lineCount = 0;
|
||
for (const line of lines) {
|
||
lineCount += Math.max(1, Math.ceil(line.length / width));
|
||
}
|
||
|
||
return lineCount;
|
||
}
|
||
|
||
#hideCursor() {
|
||
if (this.#isInteractive) {
|
||
this.#write('\u001B[?25l');
|
||
}
|
||
}
|
||
|
||
#showCursor() {
|
||
if (this.#isInteractive) {
|
||
this.#write('\u001B[?25h');
|
||
}
|
||
}
|
||
|
||
#subscribeToProcessEvents() {
|
||
process.once('SIGINT', this.#exitHandlerBound);
|
||
process.once('SIGTERM', this.#exitHandlerBound);
|
||
}
|
||
|
||
#unsubscribeFromProcessEvents() {
|
||
process.off('SIGINT', this.#exitHandlerBound);
|
||
process.off('SIGTERM', this.#exitHandlerBound);
|
||
}
|
||
|
||
#exitHandler(signal) {
|
||
if (this.isSpinning) {
|
||
this.stop();
|
||
}
|
||
|
||
// SIGINT: 128 + 2
|
||
// SIGTERM: 128 + 15
|
||
const exitCode = signal === 'SIGINT' ? 130 : (signal === 'SIGTERM' ? 143 : 1);
|
||
process.exit(exitCode);
|
||
}
|
||
}
|
||
|
||
export default function yoctoSpinner(options) {
|
||
return new YoctoSpinner(options);
|
||
}
|