src/utils/spinner.js 4.8 K raw
1
// https://github.com/sindresorhus/yocto-spinner/blob/main/index.js
2
import process from "node:process";
3
import { stripVTControlCharacters } from "node:util";
4
import * as yoctocolors from "./colors";
5
6
const isUnicodeSupported =
7
	process.platform !== "win32" ||
8
	Boolean(process.env.WT_SESSION) || // Windows Terminal
9
	process.env.TERM_PROGRAM === "vscode";
10
11
const isInteractive = (stream) =>
12
	Boolean(
13
		stream.isTTY && process.env.TERM !== "dumb" && !("CI" in process.env),
14
	);
15
16
const infoSymbol = yoctocolors.blue(isUnicodeSupported ? "ℹ" : "i");
17
const successSymbol = yoctocolors.green(isUnicodeSupported ? "✔" : "√");
18
const warningSymbol = yoctocolors.yellow(isUnicodeSupported ? "⚠" : "‼");
19
const errorSymbol = yoctocolors.red(isUnicodeSupported ? "✖" : "×");
20
21
const defaultSpinner = {
22
	frames: isUnicodeSupported
23
		? ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
24
		: ["-", "\\", "|", "/"],
25
	interval: 80,
26
};
27
28
class YoctoSpinner {
29
	#frames;
30
	#interval;
31
	#currentFrame = -1;
32
	#timer;
33
	#text;
34
	#stream;
35
	#color;
36
	#lines = 0;
37
	#exitHandlerBound;
38
	#isInteractive;
39
	#lastSpinnerFrameTime = 0;
40
	#isSpinning = false;
41
42
	constructor(options = {}) {
43
		const spinner = options.spinner ?? defaultSpinner;
44
		this.#frames = spinner.frames;
45
		this.#interval = spinner.interval;
46
		this.#text = options.text ?? "";
47
		this.#stream = options.stream ?? process.stderr;
48
		this.#color = options.color ?? "cyan";
49
		this.#isInteractive = isInteractive(this.#stream);
50
		this.#exitHandlerBound = this.#exitHandler.bind(this);
51
	}
52
53
	start(text) {
54
		if (text) {
55
			this.#text = text;
56
		}
57
58
		if (this.isSpinning) {
59
			return this;
60
		}
61
62
		this.#isSpinning = true;
63
		this.#hideCursor();
64
		this.#render();
65
		this.#subscribeToProcessEvents();
66
67
		// Only start the timer in interactive mode
68
		if (this.#isInteractive) {
69
			this.#timer = setInterval(() => {
70
				this.#render();
71
			}, this.#interval);
72
		}
73
74
		return this;
75
	}
76
77
	stop(finalText) {
78
		if (!this.isSpinning) {
79
			return this;
80
		}
81
82
		this.#isSpinning = false;
83
		if (this.#timer) {
84
			clearInterval(this.#timer);
85
			this.#timer = undefined;
86
		}
87
88
		this.#showCursor();
89
		this.clear();
90
		this.#unsubscribeFromProcessEvents();
91
92
		if (finalText) {
93
			this.#stream.write(`${finalText}\n`);
94
		}
95
96
		return this;
97
	}
98
99
	#symbolStop(symbol, text) {
100
		return this.stop(`${symbol} ${text ?? this.#text}`);
101
	}
102
103
	success(text) {
104
		return this.#symbolStop(successSymbol, text);
105
	}
106
107
	error(text) {
108
		return this.#symbolStop(errorSymbol, text);
109
	}
110
111
	warning(text) {
112
		return this.#symbolStop(warningSymbol, text);
113
	}
114
115
	info(text) {
116
		return this.#symbolStop(infoSymbol, text);
117
	}
118
119
	get isSpinning() {
120
		return this.#isSpinning;
121
	}
122
123
	get text() {
124
		return this.#text;
125
	}
126
127
	set text(value) {
128
		this.#text = value ?? "";
129
		this.#render();
130
	}
131
132
	get color() {
133
		return this.#color;
134
	}
135
136
	set color(value) {
137
		this.#color = value;
138
		this.#render();
139
	}
140
141
	clear() {
142
		if (!this.#isInteractive) {
143
			return this;
144
		}
145
146
		this.#stream.cursorTo(0);
147
148
		for (let index = 0; index < this.#lines; index++) {
149
			if (index > 0) {
150
				this.#stream.moveCursor(0, -1);
151
			}
152
153
			this.#stream.clearLine(1);
154
		}
155
156
		this.#lines = 0;
157
158
		return this;
159
	}
160
161
	#render() {
162
		// Ensure we only update the spinner frame at the wanted interval,
163
		// even if the frame method is called more often.
164
		const now = Date.now();
165
		if (
166
			this.#currentFrame === -1 ||
167
			now - this.#lastSpinnerFrameTime >= this.#interval
168
		) {
169
			this.#currentFrame = ++this.#currentFrame % this.#frames.length;
170
			this.#lastSpinnerFrameTime = now;
171
		}
172
173
		const applyColor = yoctocolors[this.#color] ?? yoctocolors.cyan;
174
		const frame = this.#frames[this.#currentFrame];
175
		let string = `${applyColor(frame)} ${this.#text}`;
176
177
		if (!this.#isInteractive) {
178
			string += "\n";
179
		}
180
181
		this.clear();
182
		this.#write(string);
183
184
		if (this.#isInteractive) {
185
			this.#lines = this.#lineCount(string);
186
		}
187
	}
188
189
	#write(text) {
190
		this.#stream.write(text);
191
	}
192
193
	#lineCount(text) {
194
		const width = this.#stream.columns ?? 80;
195
		const lines = stripVTControlCharacters(text).split("\n");
196
197
		let lineCount = 0;
198
		for (const line of lines) {
199
			lineCount += Math.max(1, Math.ceil(line.length / width));
200
		}
201
202
		return lineCount;
203
	}
204
205
	#hideCursor() {
206
		if (this.#isInteractive) {
207
			this.#write("\u001B[?25l");
208
		}
209
	}
210
211
	#showCursor() {
212
		if (this.#isInteractive) {
213
			this.#write("\u001B[?25h");
214
		}
215
	}
216
217
	#subscribeToProcessEvents() {
218
		process.once("SIGINT", this.#exitHandlerBound);
219
		process.once("SIGTERM", this.#exitHandlerBound);
220
	}
221
222
	#unsubscribeFromProcessEvents() {
223
		process.off("SIGINT", this.#exitHandlerBound);
224
		process.off("SIGTERM", this.#exitHandlerBound);
225
	}
226
227
	#exitHandler(signal) {
228
		if (this.isSpinning) {
229
			this.stop();
230
		}
231
232
		// SIGINT: 128 + 2
233
		// SIGTERM: 128 + 15
234
		const exitCode = signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1;
235
		process.exit(exitCode);
236
	}
237
}
238
239
export default function yoctoSpinner(options) {
240
	return new YoctoSpinner(options);
241
}