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.
279 lines (278 loc) • 10 kB
JavaScript
import chalk from 'chalk';
import isUnicodeSupported from 'is-unicode-supported';
import { linearRegression, linearRegressionLine } from 'simple-statistics';
import TimePoly from '../polyfill/timePoly.js';
import { LogLevel } from './logLevel.js';
import ProgressBar, { ProgressBarSymbol } from './progressBar.js';
const CHALK_PROGRESS_COMPLETE_DEFAULT = chalk.reset;
const CHALK_PROGRESS_IN_PROGRESS = chalk.dim;
const CHALK_PROGRESS_INCOMPLETE = chalk.grey;
const UNICODE_SUPPORTED = isUnicodeSupported();
const BAR_COMPLETE_CHAR = UNICODE_SUPPORTED ? 'â– ' : 'â–¬';
const BAR_IN_PROGRESS_CHAR = UNICODE_SUPPORTED ? 'â– ' : 'â–¬';
const BAR_INCOMPLETE_CHAR = UNICODE_SUPPORTED ? 'â– ' : 'â–¬';
const DEFAULT_ETA = '--:--:--';
const clamp = (val, min, max) => Math.min(Math.max(val ?? 0, min), max);
/**
* A single progress bar, to be used within a {@link MultiBar}.
*/
export default class SingleBar extends ProgressBar {
static BAR_SIZE = 30;
multiBar;
logger;
displayDelay;
displayCreated;
indentSize;
symbol;
name;
showProgressNewline;
progressBarSizeMultiplier;
progressFormatter;
completed;
inProgress;
total;
finishedMessage;
lastOutput;
valueTimeBuffer = [];
lastEtaCalculatedTime = 0;
lastEtaCalculated = 0;
lastEtaFormatTime = 0;
lastEtaFormatted = DEFAULT_ETA;
constructor(multiBar, logger, options) {
super();
this.multiBar = multiBar;
this.logger = logger;
if (options?.displayDelay !== undefined) {
this.displayDelay = options.displayDelay;
this.displayCreated = TimePoly.hrtimeMillis();
}
this.indentSize = options?.indentSize ?? 0;
this.symbol = options?.symbol;
this.name = options?.name;
this.showProgressNewline = options?.showProgressNewline ?? true;
this.progressBarSizeMultiplier = options?.progressBarSizeMultiplier ?? 1;
this.progressFormatter =
options?.progressFormatter ?? ((progress) => progress.toLocaleString());
this.completed = options?.completed ?? 0;
this.inProgress = options?.inProgress ?? 0;
this.total = options?.total ?? 0;
this.finishedMessage = options?.finishedMessage;
}
/**
* A child progress bar to this progress bar.
*/
addChildBar(options) {
return this.multiBar.addSingleBar(this.logger, {
displayDelay: 2000,
indentSize: this.indentSize + (this.symbol?.symbol ? 2 : 0),
progressBarSizeMultiplier: this.progressBarSizeMultiplier / 2,
showProgressNewline: false,
...options,
}, this);
}
getIndentSize() {
return this.indentSize;
}
getSymbol() {
return this.symbol;
}
setSymbol(symbol) {
if (this.symbol === symbol) {
return;
}
this.symbol = symbol;
}
getName() {
return this.name;
}
setName(name) {
if (this.name === name) {
return;
}
this.name = name;
}
/**
* Reset the completed, in-progress, and total values of the progress bar.
*/
resetProgress(total) {
if (this.displayDelay !== undefined) {
this.displayCreated = TimePoly.hrtimeMillis();
}
this.completed = 0;
this.inProgress = 0;
this.total = total;
this.valueTimeBuffer = [];
}
/**
* Increment the completed count by the given increment (default: 1).
*/
incrementCompleted(increment = 1) {
this.completed += increment;
this.inProgress = Math.max(this.inProgress - increment, 0);
}
setCompleted(completed) {
this.completed = completed;
}
/**
* Increment the in-progress count by the given increment (default: 1).
*/
incrementInProgress(increment = 1) {
this.inProgress += increment;
}
setInProgress(inProgress) {
this.inProgress = inProgress;
}
/**
* Increment the total count by the given increment (default: 1).
*/
incrementTotal(increment = 1) {
this.total += increment;
}
setTotal(total) {
this.total = total;
}
/**
* Set the completed value to the total, and store a finished message.
*/
finish(finishedMessage) {
if (this.symbol?.symbol) {
this.setSymbol(ProgressBarSymbol.DONE);
}
if (this.total > 0) {
this.setCompleted(this.total);
}
else {
this.setCompleted(1);
}
this.setInProgress(0);
this.finishedMessage = finishedMessage;
}
setLoggerPrefix(prefix) {
this.logger = this.logger.withLoggerPrefix(prefix);
}
/**
* Queue a log message to be printed to the terminal.
*/
log(logLevel, message) {
if (this.logger.getLogLevel() > logLevel && this.logger.getLogLevel() !== LogLevel.ALWAYS) {
return;
}
this.multiBar.log(this.logger.formatMessage(logLevel, message));
}
/**
* Log this {@link SingleBar}'s last output and freeze it.
*/
freeze() {
this.multiBar.freezeSingleBar(this);
}
/**
* Delete this {@link SingleBar} from the {@link MultiBar}.
*/
delete() {
this.multiBar.removeSingleBar(this);
}
/**
* Return the formatted output of this progress bar.
*/
format() {
if (this.displayDelay !== undefined &&
(this.completed >= this.total ||
TimePoly.hrtimeMillis(this.displayCreated) < this.displayDelay)) {
return '';
}
this.displayDelay = undefined;
let output = ' '.repeat(this.indentSize);
if (this.symbol?.symbol) {
output += this.symbol.color(`${this.symbol.symbol} `);
}
if (this.finishedMessage) {
output += `${this.name} ${CHALK_PROGRESS_IN_PROGRESS('»')} ${this.finishedMessage}`;
this.lastOutput = output;
return this.lastOutput;
}
if (!this.showProgressNewline) {
output += `${this.getBar()} `;
}
if (this.name) {
output += `${this.name} `;
}
if (this.showProgressNewline) {
output += `\n${' '.repeat(this.indentSize + (this.symbol?.symbol ? 2 : 0))}${this.getBar()} `;
}
this.lastOutput = output.trimEnd();
return this.lastOutput;
}
getBar() {
let bar = '';
const symbolColor = (this.indentSize === 0 ? this.symbol?.color : undefined) ?? CHALK_PROGRESS_COMPLETE_DEFAULT;
const barSize = Math.floor(SingleBar.BAR_SIZE * this.progressBarSizeMultiplier) -
this.indentSize -
(this.symbol?.symbol ? 2 : 0);
const completeSize = this.total > 0 ? Math.floor(clamp(this.completed / this.total, 0, 1) * barSize) : 0;
bar += symbolColor(BAR_COMPLETE_CHAR.repeat(Math.max(completeSize, 0)));
const inProgressSize = this.total > 0
? Math.ceil((clamp(this.inProgress, 0, this.total) / this.total) * barSize)
: 0;
bar += CHALK_PROGRESS_IN_PROGRESS(BAR_IN_PROGRESS_CHAR.repeat(Math.max(inProgressSize, 0)));
const incompleteSize = barSize - inProgressSize - completeSize;
bar += CHALK_PROGRESS_INCOMPLETE(BAR_INCOMPLETE_CHAR.repeat(Math.max(incompleteSize, 0)));
bar += ' ';
const formattedCompleted = this.progressFormatter(this.completed);
const formattedTotal = this.progressFormatter(this.total);
const paddedCompleted = formattedCompleted.padStart(Math.max(formattedTotal.length, this.indentSize > 0 ? 8 : 0), ' ');
const paddedTotal = formattedTotal.padEnd(this.indentSize > 0 ? 8 : 0, ' ');
bar += `${symbolColor(paddedCompleted)}/${CHALK_PROGRESS_IN_PROGRESS(paddedTotal)} `;
if (this.completed > 0 || this.indentSize > 0) {
bar += CHALK_PROGRESS_INCOMPLETE(`[${this.getEtaFormatted()}]`);
}
return bar.trim();
}
getEtaFormatted() {
if (this.completed === 0) {
return DEFAULT_ETA;
}
const etaSeconds = this.calculateEta();
// Throttle how often the ETA can visually change
const elapsedMs = TimePoly.hrtimeMillis(this.lastEtaFormatTime);
if (etaSeconds > 60 && elapsedMs < 5000) {
return this.lastEtaFormatted;
}
this.lastEtaFormatTime = TimePoly.hrtimeMillis();
if (Math.floor(etaSeconds) < 0) {
this.lastEtaFormatted = DEFAULT_ETA;
return this.lastEtaFormatted;
}
const hours = Math.floor(etaSeconds / 3600);
const minutes = Math.floor((etaSeconds % 3600) / 60);
const seconds = Math.ceil(etaSeconds) % 60;
this.lastEtaFormatted = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
return this.lastEtaFormatted;
}
calculateEta() {
// Throttle how often the ETA is calculated
const elapsedMs = TimePoly.hrtimeMillis(this.lastEtaCalculatedTime);
if (elapsedMs < 50) {
return this.lastEtaCalculated;
}
this.lastEtaCalculatedTime = TimePoly.hrtimeMillis();
const MAX_BUFFER_SIZE = clamp(Math.floor(this.total / 10), 25, 50);
this.valueTimeBuffer = [
...this.valueTimeBuffer.slice(1 - MAX_BUFFER_SIZE),
[this.completed, Date.now()],
];
const doneTime = linearRegressionLine(linearRegression(this.valueTimeBuffer))(this.total);
if (Number.isNaN(doneTime)) {
// Vertical line
return -1;
}
const remaining = (doneTime - Date.now()) / 1000;
if (!Number.isFinite(remaining)) {
return -1;
}
this.lastEtaCalculated = Math.max(remaining, 0);
return this.lastEtaCalculated;
}
getLastOutput() {
return this.lastOutput;
}
}