From 6a4150a92e1e7734bc23aadb45274b7d74db9b9a Mon Sep 17 00:00:00 2001 From: Sindre Sorhus Date: Mon, 21 Feb 2022 13:03:22 +0700 Subject: [PATCH] Refactor --- index.d.ts | 51 +++++---- index.js | 298 +++++++++++++++++++++------------------------------ package.json | 17 +-- readme.md | 62 ++++++----- test.js | 65 +++++------ utilities.js | 85 +++++++++++++++ 6 files changed, 309 insertions(+), 269 deletions(-) create mode 100644 utilities.js diff --git a/index.d.ts b/index.d.ts index 4139d37..ff84da3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -47,7 +47,7 @@ export interface Options { readonly spinner?: SpinnerName | Spinner; /** - Color of the spinner. + The color of the spinner. @default 'cyan' */ @@ -149,20 +149,17 @@ export interface PromiseOptions extends Options { } export interface Ora { - /** - A boolean of whether the instance is currently spinning. - */ - readonly isSpinning: boolean; - /** Change the text after the spinner. */ text: string; /** - Change the text or function that returns text before the spinner. No prefix text will be displayed if set to an empty string. + Change the text or function that returns text before the spinner. + + No prefix text will be displayed if set to an empty string. */ - prefixText: string | PrefixTextGenerator; + prefixText: string; /** Change the spinner color. @@ -170,21 +167,31 @@ export interface Ora { color: Color; /** - Change the spinner. + Change the spinner indent. */ - spinner: SpinnerName | Spinner; + indent: number; /** - Change the spinner indent. + Get the spinner. */ - indent: number; + get spinner(): Spinner; + + /** + Set the spinner. + */ + set spinner(spinner: SpinnerName | Spinner); + + /** + A boolean of whether the instance is currently spinning. + */ + get isSpinning(): boolean; /** The interval between each frame. The interval is decided by the chosen spinner. */ - readonly interval: number; + get interval(): number; /** Start the spinner. @@ -192,14 +199,14 @@ export interface Ora { @param text - Set the current text. @returns The spinner instance. */ - start(text?: string): Ora; + start(text?: string): this; /** Stop and clear the spinner. @returns The spinner instance. */ - stop(): Ora; + stop(): this; /** Stop the spinner, change it to a green `✔` and persist the current text, or `text` if provided. @@ -207,7 +214,7 @@ export interface Ora { @param text - Will persist text if provided. @returns The spinner instance. */ - succeed(text?: string): Ora; + succeed(text?: string): this; /** Stop the spinner, change it to a red `✖` and persist the current text, or `text` if provided. @@ -215,7 +222,7 @@ export interface Ora { @param text - Will persist text if provided. @returns The spinner instance. */ - fail(text?: string): Ora; + fail(text?: string): this; /** Stop the spinner, change it to a yellow `⚠` and persist the current text, or `text` if provided. @@ -223,7 +230,7 @@ export interface Ora { @param text - Will persist text if provided. @returns The spinner instance. */ - warn(text?: string): Ora; + warn(text?: string): this; /** Stop the spinner, change it to a blue `ℹ` and persist the current text, or `text` if provided. @@ -231,28 +238,28 @@ export interface Ora { @param text - Will persist text if provided. @returns The spinner instance. */ - info(text?: string): Ora; + info(text?: string): this; /** Stop the spinner and change the symbol or text. @returns The spinner instance. */ - stopAndPersist(options?: PersistOptions): Ora; + stopAndPersist(options?: PersistOptions): this; /** Clear the spinner. @returns The spinner instance. */ - clear(): Ora; + clear(): this; /** Manually render a new frame. @returns The spinner instance. */ - render(): Ora; + render(): this; /** Get a new frame. diff --git a/index.js b/index.js index df3a8ce..e098c14 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,4 @@ import process from 'node:process'; -import readline from 'node:readline'; import chalk from 'chalk'; import cliCursor from 'cli-cursor'; import cliSpinners from 'cli-spinners'; @@ -8,94 +7,28 @@ import stripAnsi from 'strip-ansi'; import wcwidth from 'wcwidth'; import isInteractive from 'is-interactive'; import isUnicodeSupported from 'is-unicode-supported'; -import {BufferListStream} from 'bl'; - -const TEXT = Symbol('text'); -const PREFIX_TEXT = Symbol('prefixText'); -const ASCII_ETX_CODE = 0x03; // Ctrl+C emits this code - -// TODO: Use class fields when ESLint 8 is out. - -class StdinDiscarder { - constructor() { - this.requests = 0; - - this.mutedStream = new BufferListStream(); - this.mutedStream.pipe(process.stdout); - - const self = this; // eslint-disable-line unicorn/no-this-assignment - this.ourEmit = function (event, data, ...args) { - const {stdin} = process; - if (self.requests > 0 || stdin.emit === self.ourEmit) { - if (event === 'keypress') { // Fixes readline behavior - return; - } - - if (event === 'data' && data.includes(ASCII_ETX_CODE)) { - process.emit('SIGINT'); - } - - Reflect.apply(self.oldEmit, this, [event, data, ...args]); - } else { - Reflect.apply(process.stdin.emit, this, [event, data, ...args]); - } - }; - } - - start() { - this.requests++; - - if (this.requests === 1) { - this.realStart(); - } - } - - stop() { - if (this.requests <= 0) { - throw new Error('`stop` called more times than `start`'); - } - - this.requests--; - - if (this.requests === 0) { - this.realStop(); - } - } - - realStart() { - // No known way to make it work reliably on Windows - if (process.platform === 'win32') { - return; - } - - this.rl = readline.createInterface({ - input: process.stdin, - output: this.mutedStream, - }); - - this.rl.on('SIGINT', () => { - if (process.listenerCount('SIGINT') === 0) { - process.emit('SIGINT'); - } else { - this.rl.close(); - process.kill(process.pid, 'SIGINT'); - } - }); - } - - realStop() { - if (process.platform === 'win32') { - return; - } - - this.rl.close(); - this.rl = undefined; - } -} +import {StdinDiscarder} from './utilities.js'; let stdinDiscarder; class Ora { + #linesToClear = 0; + #isDiscardingStdin = false; + #lineCount = 0; + #frameIndex = 0; + #options; + #spinner; + #stream; + #id; + #initialInterval; + #isEnabled; + #isSilent; + #indent; + #text; + #prefixText; + + color; + constructor(options) { if (!stdinDiscarder) { stdinDiscarder = new StdinDiscarder(); @@ -107,35 +40,60 @@ class Ora { }; } - this.options = { - text: '', + this.#options = { color: 'cyan', stream: process.stderr, discardStdin: true, + hideCursor: true, ...options, }; - this.spinner = this.options.spinner; - - this.color = this.options.color; - this.hideCursor = this.options.hideCursor !== false; - this._interval = this.options.interval || this.spinner.interval || 100; - this.stream = this.options.stream; - this.id = undefined; - this.isEnabled = typeof this.options.isEnabled === 'boolean' ? this.options.isEnabled : isInteractive({stream: this.stream}); - this.isSilent = typeof this.options.isSilent === 'boolean' ? this.options.isSilent : false; - - // Set *after* `this.stream` - this.text = this.options.text; - this.prefixText = this.options.prefixText; - this.linesToClear = 0; - this.indent = this.options.indent; - this.discardStdin = this.options.discardStdin; - this.isDiscardingStdin = false; + // Public + this.color = this.#options.color; + + // It's important that these use the public setters. + this.spinner = this.#options.spinner; + + this.#initialInterval = this.#options.interval; + this.#stream = this.#options.stream; + this.#isEnabled = typeof this.#options.isEnabled === 'boolean' ? this.#options.isEnabled : isInteractive({stream: this.#stream}); + this.#isSilent = typeof this.#options.isSilent === 'boolean' ? this.#options.isSilent : false; + + // Set *after* `this.#stream`. + // It's important that these use the public setters. + this.text = this.#options.text; + this.prefixText = this.#options.prefixText; + this.indent = this.#options.indent; + + if (process.env.NODE_ENV === 'test') { + this._stream = this.#stream; + this._isEnabled = this.#isEnabled; + + Object.defineProperty(this, '_linesToClear', { + get() { + return this.#linesToClear; + }, + set(newValue) { + this.#linesToClear = newValue; + }, + }); + + Object.defineProperty(this, '_frameIndex', { + get() { + return this.#frameIndex; + }, + }); + + Object.defineProperty(this, '_lineCount', { + get() { + return this.#lineCount; + }, + }); + } } get indent() { - return this._indent; + return this.#indent; } set indent(indent = 0) { @@ -143,71 +101,65 @@ class Ora { throw new Error('The `indent` option must be an integer from 0 and up'); } - this._indent = indent; + this.#indent = indent; this.updateLineCount(); } - _updateInterval(interval) { - if (interval !== undefined) { - this._interval = interval; - } - } - get interval() { - return this._interval; + return this.#initialInterval || this.#spinner.interval || 100; } get spinner() { - return this._spinner; + return this.#spinner; } set spinner(spinner) { - this.frameIndex = 0; + this.#frameIndex = 0; + this.#initialInterval = undefined; if (typeof spinner === 'object') { if (spinner.frames === undefined) { throw new Error('The given spinner must have a `frames` property'); } - this._spinner = spinner; + this.#spinner = spinner; } else if (!isUnicodeSupported()) { - this._spinner = cliSpinners.line; + this.#spinner = cliSpinners.line; } else if (spinner === undefined) { // Set default spinner - this._spinner = cliSpinners.dots; + this.#spinner = cliSpinners.dots; } else if (spinner !== 'default' && cliSpinners[spinner]) { - this._spinner = cliSpinners[spinner]; + this.#spinner = cliSpinners[spinner]; } else { throw new Error(`There is no built-in spinner named '${spinner}'. See https://github.com/sindresorhus/cli-spinners/blob/main/spinners.json for a full list.`); } - - this._updateInterval(this._spinner.interval); } get text() { - return this[TEXT]; + return this.#text; } set text(value) { - this[TEXT] = value; + this.#text = value || ''; this.updateLineCount(); } get prefixText() { - return this[PREFIX_TEXT]; + return this.#prefixText; } set prefixText(value) { - this[PREFIX_TEXT] = value; + this.#prefixText = value || ''; this.updateLineCount(); } get isSpinning() { - return this.id !== undefined; + return this.#id !== undefined; } - getFullPrefixText(prefixText = this[PREFIX_TEXT], postfix = ' ') { - if (typeof prefixText === 'string') { + // TODO: Use private methods when targeting Node.js 14. + getFullPrefixText(prefixText = this.#prefixText, postfix = ' ') { + if (typeof prefixText === 'string' && prefixText !== '') { return prefixText + postfix; } @@ -219,16 +171,17 @@ class Ora { } updateLineCount() { - const columns = this.stream.columns || 80; - const fullPrefixText = this.getFullPrefixText(this.prefixText, '-'); - this.lineCount = 0; - for (const line of stripAnsi(' '.repeat(this.indent) + fullPrefixText + '--' + this[TEXT]).split('\n')) { - this.lineCount += Math.max(1, Math.ceil(wcwidth(line) / columns)); + const columns = this.#stream.columns || 80; + const fullPrefixText = this.getFullPrefixText(this.#prefixText, '-'); + + this.#lineCount = 0; + for (const line of stripAnsi(' '.repeat(this.#indent) + fullPrefixText + '--' + this.#text).split('\n')) { + this.#lineCount += Math.max(1, Math.ceil(wcwidth(line) / columns)); } } get isEnabled() { - return this._isEnabled && !this.isSilent; + return this.#isEnabled && !this.#isSilent; } set isEnabled(value) { @@ -236,11 +189,11 @@ class Ora { throw new TypeError('The `isEnabled` option must be a boolean'); } - this._isEnabled = value; + this.#isEnabled = value; } get isSilent() { - return this._isSilent; + return this.#isSilent; } set isSilent(value) { @@ -248,57 +201,57 @@ class Ora { throw new TypeError('The `isSilent` option must be a boolean'); } - this._isSilent = value; + this.#isSilent = value; } frame() { - const {frames} = this.spinner; - let frame = frames[this.frameIndex]; + const {frames} = this.#spinner; + let frame = frames[this.#frameIndex]; if (this.color) { frame = chalk[this.color](frame); } - this.frameIndex = ++this.frameIndex % frames.length; - const fullPrefixText = (typeof this.prefixText === 'string' && this.prefixText !== '') ? this.prefixText + ' ' : ''; + this.#frameIndex = ++this.#frameIndex % frames.length; + const fullPrefixText = (typeof this.#prefixText === 'string' && this.#prefixText !== '') ? this.#prefixText + ' ' : ''; const fullText = typeof this.text === 'string' ? ' ' + this.text : ''; return fullPrefixText + frame + fullText; } clear() { - if (!this.isEnabled || !this.stream.isTTY) { + if (!this.#isEnabled || !this.#stream.isTTY) { return this; } - this.stream.cursorTo(0); + this.#stream.cursorTo(0); - for (let index = 0; index < this.linesToClear; index++) { + for (let index = 0; index < this.#linesToClear; index++) { if (index > 0) { - this.stream.moveCursor(0, -1); + this.#stream.moveCursor(0, -1); } - this.stream.clearLine(1); + this.#stream.clearLine(1); } - if (this.indent || this.lastIndent !== this.indent) { - this.stream.cursorTo(this.indent); + if (this.#indent || this.lastIndent !== this.#indent) { + this.#stream.cursorTo(this.#indent); } - this.lastIndent = this.indent; - this.linesToClear = 0; + this.lastIndent = this.#indent; + this.#linesToClear = 0; return this; } render() { - if (this.isSilent) { + if (this.#isSilent) { return this; } this.clear(); - this.stream.write(this.frame()); - this.linesToClear = this.lineCount; + this.#stream.write(this.frame()); + this.#linesToClear = this.#lineCount; return this; } @@ -308,13 +261,13 @@ class Ora { this.text = text; } - if (this.isSilent) { + if (this.#isSilent) { return this; } - if (!this.isEnabled) { + if (!this.#isEnabled) { if (this.text) { - this.stream.write(`- ${this.text}\n`); + this.#stream.write(`- ${this.text}\n`); } return this; @@ -324,37 +277,37 @@ class Ora { return this; } - if (this.hideCursor) { - cliCursor.hide(this.stream); + if (this.#options.hideCursor) { + cliCursor.hide(this.#stream); } - if (this.discardStdin && process.stdin.isTTY) { - this.isDiscardingStdin = true; + if (this.#options.discardStdin && process.stdin.isTTY) { + this.#isDiscardingStdin = true; stdinDiscarder.start(); } this.render(); - this.id = setInterval(this.render.bind(this), this._interval); + this.#id = setInterval(this.render.bind(this), this.interval); return this; } stop() { - if (!this.isEnabled) { + if (!this.#isEnabled) { return this; } - clearInterval(this.id); - this.id = undefined; - this.frameIndex = 0; + clearInterval(this.#id); + this.#id = undefined; + this.#frameIndex = 0; this.clear(); - if (this.hideCursor) { - cliCursor.show(this.stream); + if (this.#options.hideCursor) { + cliCursor.show(this.#stream); } - if (this.discardStdin && process.stdin.isTTY && this.isDiscardingStdin) { + if (this.#options.discardStdin && process.stdin.isTTY && this.#isDiscardingStdin) { stdinDiscarder.stop(); - this.isDiscardingStdin = false; + this.#isDiscardingStdin = false; } return this; @@ -377,16 +330,16 @@ class Ora { } stopAndPersist(options = {}) { - if (this.isSilent) { + if (this.#isSilent) { return this; } - const prefixText = options.prefixText || this.prefixText; + const prefixText = options.prefixText || this.#prefixText; const text = options.text || this.text; const fullText = (typeof text === 'string') ? ' ' + text : ''; this.stop(); - this.stream.write(`${this.getFullPrefixText(prefixText, ' ')}${options.symbol || ' '}${fullText}\n`); + this.#stream.write(`${this.getFullPrefixText(prefixText, ' ')}${options.symbol || ' '}${fullText}\n`); return this; } @@ -398,7 +351,6 @@ export default function ora(options) { export async function oraPromise(action, options) { const actionIsFunction = typeof action === 'function'; - // eslint-disable-next-line promise/prefer-await-to-then const actionIsPromise = typeof action.then === 'function'; if (!actionIsFunction && !actionIsPromise) { diff --git a/package.json b/package.json index 918c52f..e691a68 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ }, "files": [ "index.js", - "index.d.ts" + "index.d.ts", + "utilities.js" ], "keywords": [ "cli", @@ -40,21 +41,21 @@ ], "dependencies": { "bl": "^5.0.0", - "chalk": "^4.1.2", + "chalk": "^5.0.0", "cli-cursor": "^4.0.0", - "cli-spinners": "^2.6.0", + "cli-spinners": "^2.6.1", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.1.0", - "log-symbols": "^5.0.0", + "log-symbols": "^5.1.0", "strip-ansi": "^7.0.1", "wcwidth": "^1.0.1" }, "devDependencies": { - "@types/node": "^16.9.1", - "ava": "^3.15.0", + "@types/node": "^17.0.18", + "ava": "^4.0.1", "get-stream": "^6.0.1", "transform-tty": "^1.0.11", - "tsd": "^0.17.0", - "xo": "^0.44.0" + "tsd": "^0.19.1", + "xo": "^0.48.0" } } diff --git a/readme.md b/readme.md index eae7e71..e837459 100644 --- a/readme.md +++ b/readme.md @@ -10,8 +10,8 @@ ## Install -``` -$ npm install ora +```sh +npm install ora ``` ## Usage @@ -72,7 +72,7 @@ Type: `string`\ Default: `'cyan'`\ Values: `'black' | 'red' | 'green' | 'yellow' | 'blue' | 'magenta' | 'cyan' | 'white' | 'gray'` -Color of the spinner. +The color of the spinner. ##### hideCursor @@ -132,6 +132,38 @@ This has no effect on Windows as there's no good way to implement discarding std ### Instance +#### .text get/set + +Change the text after the spinner. + +#### .prefixText get/set + +Change the text before the spinner. + +No prefix text will be displayed if set to an empty string. + +#### .color get/set + +Change the spinner color. + +#### .spinner get/set + +Change the spinner. + +#### .indent get/set + +Change the spinner indent. + +#### .isSpinning get + +A boolean of whether the instance is currently spinning. + +#### .interval get + +The interval between each frame. + +The interval is decided by the chosen spinner. + #### .start(text?) Start the spinner. Returns the instance. Set the current text if `text` is provided. @@ -156,10 +188,6 @@ Stop the spinner, change it to a yellow `⚠` and persist the current text, or ` Stop the spinner, change it to a blue `ℹ` and persist the current text, or `text` if provided. Returns the instance. -#### .isSpinning - -A boolean of whether the instance is currently spinning. - #### .stopAndPersist(options?) Stop the spinner and change the symbol or text. Returns the instance. See the GIF below. @@ -203,26 +231,6 @@ Manually render a new frame. Returns the instance. Get a new frame. -#### .text - -Change the text after the spinner. - -#### .prefixText - -Change the text before the spinner. No prefix text will be displayed if set to an empty string. - -#### .color - -Change the spinner color. - -#### .spinner - -Change the spinner. - -#### .indent - -Change the spinner indent. - ### oraPromise(action, text) ### oraPromise(action, options) diff --git a/test.js b/test.js index 95fb2dc..c2a59a4 100644 --- a/test.js +++ b/test.js @@ -46,26 +46,8 @@ test('main', macro, spinner => { spinner.stop(); }, new RegExp(`${spinnerCharacter} foo`)); -test('title shortcut', async t => { - const stream = getPassThroughStream(); - const output = getStream(stream); - - const spinner = ora('foo'); - spinner.stream = stream; - spinner.color = false; - spinner.isEnabled = true; - spinner.start(); - t.true(spinner.isSpinning); - spinner.stop(); - - stream.end(); - - t.is(await output, `${spinnerCharacter} foo`); -}); - test('`.id` is not set when created', t => { const spinner = ora('foo'); - t.falsy(spinner.id); t.false(spinner.isSpinning); }); @@ -84,8 +66,8 @@ test('chain call to `.start()` with constructor', t => { isEnabled: true, }).start(); - t.truthy(spinner.id); - t.true(spinner.isEnabled); + t.truthy(spinner.isSpinning); + t.true(spinner._isEnabled); }); test('.succeed()', macro, spinner => { @@ -268,18 +250,23 @@ test('reset frameIndex when setting new spinner', async t => { const spinner = ora({ stream, isEnabled: true, - spinner: {frames: ['foo', 'fooo']}, + spinner: { + frames: [ + 'foo', + 'fooo', + ], + }, }); spinner.render(); - t.is(spinner.frameIndex, 1); + t.is(spinner._frameIndex, 1); spinner.spinner = {frames: ['baz']}; spinner.render(); stream.end(); - t.is(spinner.frameIndex, 0); + t.is(spinner._frameIndex, 0); t.regex(stripAnsi(await output), /foo baz/); }); @@ -385,11 +372,11 @@ test('handles wrapped lines when length of indent + text is greater than columns spinner.render(); - spinner.text = '0'.repeat(spinner.stream.columns - 5); + spinner.text = '0'.repeat(spinner._stream.columns - 5); spinner.indent = 15; spinner.render(); - t.is(spinner.lineCount, 2); + t.is(spinner._lineCount, 2); }); test('.stopAndPersist() with prefixText', macro, spinner => { @@ -430,28 +417,28 @@ const currentClearMethod = transFormTTY => { let firstIndent = true; spinner.clear = function () { - if (!this.isEnabled || !this.stream.isTTY) { + if (!this._isEnabled || !this._stream.isTTY) { return this; } - for (let index = 0; index < this.linesToClear; index++) { + for (let index = 0; index < this._linesToClear; index++) { if (index > 0) { - this.stream.moveCursor(0, -1); + this._stream.moveCursor(0, -1); } - this.stream.clearLine(); - this.stream.cursorTo(this.indent); + this._stream.clearLine(); + this._stream.cursorTo(this.indent); } // It's too quick to be noticeable, but indent does not get applied // for the first render if `linesToClear === 0`. The new clear method // doesn't have this issue, since it's called outside of the loop. - if (this.linesToClear === 0 && firstIndent && this.indent) { - this.stream.cursorTo(this.indent); + if (this._linesToClear === 0 && firstIndent && this.indent) { + this._stream.cursorTo(this.indent); firstIndent = false; } - this.linesToClear = 0; + this._linesToClear = 0; return this; }.bind(spinner); @@ -572,36 +559,36 @@ test('new clear method test, erases wrapped lines', t => { t.is(cursorAtRow(), -2); currentOra.clear(); - currentOra.text = '0'.repeat(currentOra.stream.columns + 10); + currentOra.text = '0'.repeat(currentOra._stream.columns + 10); currentOra.render(); currentOra.render(); spinner.clear(); - spinner.text = '0'.repeat(spinner.stream.columns + 10); + spinner.text = '0'.repeat(spinner._stream.columns + 10); spinner.render(); spinner.render(); t.is(clearedLines(), 2); t.is(cursorAtRow(), -1); currentOra.clear(); - currentOra.text = '🦄'.repeat(currentOra.stream.columns + 10); + currentOra.text = '🦄'.repeat(currentOra._stream.columns + 10); currentOra.render(); currentOra.render(); spinner.clear(); - spinner.text = '🦄'.repeat(spinner.stream.columns + 10); + spinner.text = '🦄'.repeat(spinner._stream.columns + 10); spinner.render(); spinner.render(); t.is(clearedLines(), 3); t.is(cursorAtRow(), -2); currentOra.clear(); - currentOra.text = '🦄'.repeat(currentOra.stream.columns - 2) + '\nfoo'; + currentOra.text = '🦄'.repeat(currentOra._stream.columns - 2) + '\nfoo'; currentOra.render(); currentOra.render(); spinner.clear(); - spinner.text = '🦄'.repeat(spinner.stream.columns - 2) + '\nfoo'; + spinner.text = '🦄'.repeat(spinner._stream.columns - 2) + '\nfoo'; spinner.render(); spinner.render(); t.is(clearedLines(), 3); diff --git a/utilities.js b/utilities.js new file mode 100644 index 0000000..d4c9731 --- /dev/null +++ b/utilities.js @@ -0,0 +1,85 @@ +import process from 'node:process'; +import readline from 'node:readline'; +import {BufferListStream} from 'bl'; + +const ASCII_ETX_CODE = 0x03; // Ctrl+C emits this code + +export class StdinDiscarder { + #requests = 0; + #mutedStream = new BufferListStream(); + #ourEmit; + #rl; + + constructor() { + this.#mutedStream.pipe(process.stdout); + + const self = this; // eslint-disable-line unicorn/no-this-assignment + this.#ourEmit = function (event, data, ...args) { + const {stdin} = process; + if (self.#requests > 0 || stdin.emit === self.#ourEmit) { + if (event === 'keypress') { // Fixes readline behavior + return; + } + + if (event === 'data' && data.includes(ASCII_ETX_CODE)) { + process.emit('SIGINT'); + } + + Reflect.apply(self.#ourEmit, this, [event, data, ...args]); + } else { + Reflect.apply(process.stdin.emit, this, [event, data, ...args]); + } + }; + } + + start() { + this.#requests++; + + if (this.#requests === 1) { + this._realStart(); + } + } + + stop() { + if (this.#requests <= 0) { + throw new Error('`stop` called more times than `start`'); + } + + this.#requests--; + + if (this.#requests === 0) { + this._realStop(); + } + } + + // TODO: Use private methods when targeting Node.js 14. + _realStart() { + // No known way to make it work reliably on Windows + if (process.platform === 'win32') { + return; + } + + this.#rl = readline.createInterface({ + input: process.stdin, + output: this.#mutedStream, + }); + + this.#rl.on('SIGINT', () => { + if (process.listenerCount('SIGINT') === 0) { + process.emit('SIGINT'); + } else { + this.#rl.close(); + process.kill(process.pid, 'SIGINT'); + } + }); + } + + _realStop() { + if (process.platform === 'win32') { + return; + } + + this.#rl.close(); + this.#rl = undefined; + } +}