igir
Version:
🕹 A zero-setup ROM collection manager that sorts, filters, extracts or archives, patches, and reports on collections of any size on any OS.
221 lines (220 loc) • 7.51 kB
JavaScript
import tty from 'node:tty';
import stripAnsi from 'strip-ansi';
import Timer from '../async/timer.js';
import SingleBar from './singleBar.js';
const exitHandler = () => {
MultiBar.stop();
};
process.once('exit', exitHandler);
process.once('SIGINT', exitHandler);
process.once('SIGTERM', exitHandler);
/**
* A wrapper for multiple {@link SingleBar}s. Should be treated as a singleton.
*/
export default class MultiBar {
static RENDER_MIN_FPS = 5;
static OUTPUT_PADDING = ' ';
static multiBars = [];
static logQueue = [];
static lastPrintedLog;
singleBars = [];
renderTimer;
lastOutput = '';
stopped = false;
terminal;
terminalColumns = 65_536;
terminalRows = 65_536;
constructor(options) {
this.terminal = options?.writable ?? process.stdout;
// Disable the cursor
if (this.terminal instanceof tty.WriteStream) {
this.terminal.write('\x1B[?25l');
}
// Set a maximum size for the MultiBar based on terminal size
if (this.terminal instanceof tty.WriteStream) {
const onResize = () => {
if (!(this.terminal instanceof tty.WriteStream)) {
return;
}
this.terminalColumns = this.terminal.columns;
this.terminalRows = this.terminal.rows;
this.clearAndRender();
};
process.on('SIGWINCH', onResize);
onResize();
}
}
/**
* Create a new {@link MultiBar} instance.
*/
static create(options) {
const multiBar = new MultiBar(options);
this.multiBars.push(multiBar);
return multiBar;
}
/**
* Add a new {@link SingleBar} to the {@link MultiBar}.
*/
addSingleBar(logger, options, parentSingleBar) {
const singleBar = new SingleBar(this, logger, options);
const parentSingleBarIndex = parentSingleBar
? this.singleBars.indexOf(parentSingleBar)
: undefined;
const insertionIndex = parentSingleBarIndex === undefined
? undefined
: this.singleBars.findIndex((singleBar, idx) => idx > parentSingleBarIndex && singleBar.getIndentSize() === 0);
if (insertionIndex === undefined || insertionIndex === -1) {
this.singleBars.push(singleBar);
}
else {
this.singleBars.splice(insertionIndex, 0, singleBar);
}
return singleBar;
}
/**
* Log the {@link SingleBar}'s last output and remove it.
*/
freezeSingleBar(singleBar) {
const idx = this.singleBars.indexOf(singleBar);
if (idx === -1) {
return;
}
// Render one last time, then log the output
this.clearAndRender();
const lastOutput = singleBar.getLastOutput();
if (lastOutput !== undefined) {
this.log(`${singleBar.getIndentSize() === 0 ? '\n' : ''}${MultiBar.OUTPUT_PADDING}${lastOutput}`);
}
// Remove the single bar
this.singleBars.splice(idx, 1);
}
/**
* Remove a {@link SingleBar}.
*/
removeSingleBar(singleBar) {
const idx = this.singleBars.indexOf(singleBar);
if (idx === -1) {
return;
}
this.singleBars.splice(idx, 1);
}
/**
* Queue a log message to be printed to the terminal.
*/
static log(message) {
const lastPrintedLog = MultiBar.logQueue.length > 0 ? MultiBar.logQueue.at(-1) : MultiBar.lastPrintedLog;
const isFrozenPattern = new RegExp(`^\n*${MultiBar.OUTPUT_PADDING}`);
const lastPrintedLogIsFrozen = lastPrintedLog !== undefined && isFrozenPattern.test(lastPrintedLog);
const thisMessageIsFrozen = isFrozenPattern.test(message);
if (lastPrintedLogIsFrozen) {
if (thisMessageIsFrozen) {
// Print frozen progress bars next to each other
message = message.replace(/^\n+/, '');
}
else {
// Otherwise, add a newline after the previous frozen progress bar
MultiBar.logQueue.push('\n');
}
}
MultiBar.logQueue.push(`${message}\n`);
}
/**
* Queue a log message to be printed to the terminal.
*/
log(message) {
MultiBar.log(message);
}
/**
* Clear the last output and render the progress bars.
*/
clearAndRender() {
if (this.stopped) {
return;
}
this.renderTimer?.cancel();
this.renderTimer = Timer.setTimeout(() => {
this.clearAndRender();
}, Math.max(1000 / MultiBar.RENDER_MIN_FPS));
const outputLines = this.singleBars
.flatMap((singleBar) => {
const lines = singleBar
.format()
.split('\n')
.filter((line) => line !== '');
if (singleBar.getIndentSize() === 0) {
return ['', ...lines];
}
return lines;
})
.slice(0, this.terminalRows - 1)
.map((line) => {
const stripChars = stripAnsi(line).length - this.terminalColumns + 10;
if (stripChars <= 0) {
return `${MultiBar.OUTPUT_PADDING}${line}`;
}
return `${MultiBar.OUTPUT_PADDING}${line.slice(0, line.length - stripChars)}…`;
});
const output = `${outputLines.join('\n')}\n`;
if (output === this.lastOutput && MultiBar.logQueue.length === 0) {
// Nothing new to render
return;
}
// Clear the terminal
if (this.terminal instanceof tty.WriteStream) {
// TODO(cemmer): some kind of line diffing algorithm so not every line has to be repainted
let rows = 0;
for (const char of this.lastOutput) {
if (char === '\n') {
rows += 1;
}
}
if (rows > 0) {
this.terminal.moveCursor(0, -rows);
this.terminal.cursorTo(0, undefined);
this.terminal.clearScreenDown();
}
}
// Write out all queued logs
let log = MultiBar.logQueue.shift();
while (log !== undefined) {
MultiBar.lastPrintedLog = log;
this.terminal.write(log);
log = MultiBar.logQueue.shift();
}
// Write the progress bars
if (this.terminal instanceof tty.WriteStream) {
this.terminal.write(output);
}
this.lastOutput = output;
}
/**
* Stop the {@link MultiBar} and all of its {@link SingleBar}s.
*/
static stop() {
let multiBar = this.multiBars.shift();
while (multiBar !== undefined) {
multiBar.stop();
multiBar = this.multiBars.shift();
}
}
/**
* Stop the {@link MultiBar} and all of its {@link SingleBar}s.
*/
stop() {
if (this.stopped) {
return;
}
// One last render
this.clearAndRender();
// Freeze (and delete) any lingering progress bars
const singleBarsCopy = [...this.singleBars];
singleBarsCopy.forEach((progressBar) => {
progressBar.freeze();
});
// Restore the cursor
if (this.terminal instanceof tty.WriteStream) {
this.terminal.write('\x1B[?25h');
}
this.stopped = true;
}
}