Skip to content

Commit 4795283

Browse files
authored
feat: add configurable output stream (#13)
1 parent 58473f7 commit 4795283

5 files changed

Lines changed: 85 additions & 16 deletions

File tree

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,19 @@ setTimeout(() => {
6363

6464
Calling `spinner.stop();` will stop the spinner and remove it.
6565

66+
### Output stream
67+
68+
Spinners write to `process.stdout` by default. To keep stdout available for machine-readable output, pass another stream such as `process.stderr`:
69+
70+
```js
71+
import {Spinner} from 'picospinner';
72+
73+
const spinner = new Spinner({
74+
text: 'Loading...',
75+
stream: process.stderr
76+
});
77+
```
78+
6679
### Colours
6780

6881
As of version **3.0.0** colours are enabled by default. This feature uses the [`styleText`](https://nodejs.org/api/util.html#utilstyletextformat-text-options) function from [`node:util`](https://nodejs.org/api/util.html) if it is available (Node versions greater than **22.0.0**, **21.7.0** or **20.12.0**). If it's not available, no colours will be displayed.

src/index.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import * as constants from './constants.js';
2-
import {Renderer, TextComponent} from './renderer.js';
2+
import {Renderer, TextComponent, type OutputStream} from './renderer.js';
33
import * as util from 'node:util';
44

5+
export type {OutputStream} from './renderer.js';
6+
57
export type Formatter = (input: string) => string;
68

79
export type DisplayOptions = {
810
symbolFormatter?: Formatter;
911
text?: string;
1012
symbol?: string;
1113
symbolType?: 'succeed' | 'fail' | 'warn' | 'info' | 'spinner';
14+
stream?: OutputStream;
1215
};
1316

1417
export type SpinnerOptions = {
@@ -39,6 +42,8 @@ export type Symbols = {
3942
// Global renderer so that multiple spinners can run at the same time
4043
export const renderer = new Renderer();
4144

45+
const renderersByStream = new WeakMap<OutputStream, Renderer>([[process.stdout, renderer]]);
46+
4247
export class Spinner {
4348
public running = false;
4449

@@ -51,6 +56,8 @@ export class Spinner {
5156
private frames: string[];
5257
private component = new TextComponent('');
5358
private colors?: ColorOptions;
59+
private stream: OutputStream = process.stdout;
60+
private outputRenderer = renderer;
5461

5562
constructor(display: DisplayOptions | string = '', {disableNewLineEnding, colors, frames = constants.DEFAULT_FRAMES, symbols = {} as Partial<Symbols>}: SpinnerOptions = {}) {
5663
// Merge symbols with defaults
@@ -85,7 +92,7 @@ export class Spinner {
8592
this.currentSymbol = this.frames[0];
8693

8794
this.tick();
88-
renderer.addComponent(this.component);
95+
this.outputRenderer.addComponent(this.component);
8996
this.addListeners();
9097
}
9198

@@ -130,6 +137,7 @@ export class Spinner {
130137
}
131138

132139
setDisplay(displayOpts: DisplayOptions = {}, render = true) {
140+
if (displayOpts.stream) this.setStream(displayOpts.stream);
133141
if (typeof displayOpts.symbol === 'string') {
134142
if (typeof displayOpts.symbolType === 'string') {
135143
this.currentSymbol = this.format(displayOpts.symbol, displayOpts.symbolType);
@@ -142,6 +150,23 @@ export class Spinner {
142150
if (typeof displayOpts.symbol === 'string') this.end();
143151
}
144152

153+
private setStream(stream: OutputStream) {
154+
if (stream === this.stream) return;
155+
156+
const wasRunning = this.running;
157+
if (wasRunning) this.outputRenderer.removeComponent(this.component);
158+
159+
this.stream = stream;
160+
let outputRenderer = renderersByStream.get(stream);
161+
if (!outputRenderer) {
162+
outputRenderer = new Renderer(true, stream);
163+
renderersByStream.set(stream, outputRenderer);
164+
}
165+
this.outputRenderer = outputRenderer;
166+
167+
if (wasRunning) this.outputRenderer.addComponent(this.component);
168+
}
169+
145170
setText(text: string, render = true) {
146171
this.text = this.format(text, 'text');
147172
if (this.running) {
@@ -177,7 +202,7 @@ export class Spinner {
177202
clearInterval(this.interval);
178203
this.clearListeners();
179204
if (keepComponent) this.component.finish();
180-
else renderer.removeComponent(this.component);
205+
else this.outputRenderer.removeComponent(this.component);
181206
this.running = false;
182207
}
183208

src/renderer.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import * as constants from './constants.js';
22
import {countLines} from './string-lines.js';
3+
import type {Writable} from 'node:stream';
4+
5+
export interface OutputStream extends Writable {
6+
getWindowSize?: () => [number, number];
7+
}
38

49
export class TextComponent {
510
onChange?: () => void;
@@ -38,22 +43,25 @@ export class Renderer {
3843
private finishedComponents = 0;
3944
private outputBuffer = '';
4045

41-
constructor(public hideCursor: boolean = true) {}
46+
constructor(
47+
public hideCursor: boolean = true,
48+
private stream: OutputStream = process.stdout
49+
) {}
4250

4351
addComponent(component: TextComponent) {
4452
this.components.push(component);
4553
component.onChange = this.render.bind(this);
4654
component.onFinish = this.onComponentFinish.bind(this);
4755

48-
if (process.stdout.getWindowSize) this.terminalWidth = process.stdout.getWindowSize()[0];
56+
if (this.stream.getWindowSize) this.terminalWidth = this.stream.getWindowSize()[0];
4957
this.render();
5058
}
5159

5260
private onComponentFinish() {
5361
this.finishedComponents++;
5462
if (this.finishedComponents === this.components.length) {
5563
this._reset();
56-
process.stdout.write(constants.SHOW_CURSOR);
64+
this.stream.write(constants.SHOW_CURSOR);
5765
}
5866
}
5967

@@ -69,8 +77,8 @@ export class Renderer {
6977
this.clear();
7078

7179
if (this.components.length === 0) {
72-
process.stdout.write(this.outputBuffer);
73-
if (this.hideCursor) process.stdout.write(constants.SHOW_CURSOR);
80+
this.stream.write(this.outputBuffer);
81+
if (this.hideCursor) this.stream.write(constants.SHOW_CURSOR);
7482
this.lastLinesAmt = 0;
7583
return;
7684
}
@@ -93,7 +101,7 @@ export class Renderer {
93101
this.outputBuffer += constants.SHOW_CURSOR;
94102
}
95103

96-
process.stdout.write(this.outputBuffer);
104+
this.stream.write(this.outputBuffer);
97105
}
98106

99107
clear() {

src/test/spinner-test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as assert from 'node:assert/strict';
22
import {test} from 'node:test';
33
import {ColorOptions, Spinner, Symbols, renderer} from '../index.js';
4-
import {createFinishingRenderedLine, createRenderedLine, createRenderedOutput, interceptStdout, suppressStdout, TickMeasuredSpinner} from './utils.js';
4+
import {createFinishingRenderedLine, createRenderedLine, createRenderedOutput, interceptStderr, interceptStdout, suppressStdout, TickMeasuredSpinner} from './utils.js';
55
import process from 'node:process';
66
import * as constants from '../constants.js';
77

@@ -254,4 +254,19 @@ test('spinner', async (t) => {
254254
);
255255
await t.test('displays colors with symbol formatter', () => testSpinner(undefined, 'lorem ipsum dolor', (s) => `a${s}a`, true));
256256
await t.test('disableNewLineEnding prevents ending new line', () => testSpinner(undefined, undefined, undefined, undefined, true));
257+
await t.test('writes to configured stream', async () => {
258+
renderer._reset();
259+
260+
let stdout = '';
261+
const stderr = await interceptStderr(async () => {
262+
stdout = await interceptStdout(async () => {
263+
const spinner = new Spinner({stream: process.stderr}, {colors: false});
264+
spinner.start();
265+
spinner.stop();
266+
});
267+
});
268+
269+
assert.equal(stdout, '');
270+
assert.equal(stderr, createRenderedLine(constants.DEFAULT_FRAMES[0], '', true) + constants.CLEAR_LINE + constants.UP_LINE + constants.CLEAR_LINE + constants.SHOW_CURSOR);
271+
});
257272
});

src/test/utils.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,25 +42,33 @@ export function suppressStdout() {
4242
return () => (process.stdout.write = stdoutWrite);
4343
}
4444

45-
export async function interceptStdout(exec: () => Promise<void> | void) {
45+
async function interceptOutput(stream: NodeJS.WriteStream, exec: () => Promise<void> | void) {
4646
let output = '';
47-
const stdoutWrite = process.stdout.write.bind(process.stdout);
47+
const streamWrite = stream.write.bind(stream);
4848

4949
// @ts-expect-error - types are wrong here for the callback
50-
capcon.startIntercept(process.stdout, (data: string) => {
51-
// Since stdout is used to communicate test data, the interceptor should write data that is not from picospinner to stdout
50+
capcon.startIntercept(stream, (data: string) => {
51+
// Since output streams are used to communicate test data, the interceptor should write data that is not from picospinner back to the stream
5252
if (isMainCallstack()) {
5353
output += data;
54-
} else stdoutWrite(data);
54+
} else streamWrite(data);
5555
});
5656

5757
await exec();
5858

59-
capcon.stopIntercept(process.stdout);
59+
capcon.stopIntercept(stream);
6060

6161
return output;
6262
}
6363

64+
export function interceptStdout(exec: () => Promise<void> | void) {
65+
return interceptOutput(process.stdout, exec);
66+
}
67+
68+
export function interceptStderr(exec: () => Promise<void> | void) {
69+
return interceptOutput(process.stderr, exec);
70+
}
71+
6472
function getCallstack() {
6573
try {
6674
throw new Error();

0 commit comments

Comments
 (0)