UNPKG

node-console-progress-bar-tqdm

Version:

Progress bar in console for Node.js in the style of TQDM Python library

347 lines (302 loc) 11.1 kB
import { ITqdmAsyncIteratorContainer, ITqdmSyncIteratorContainer, ITqdmProgress, TqdmInput, TqdmItem, TqdmOptions, TqdmUnitTable, } from './base-types'; import { TqdmAsyncResultIteratorReturn, TqdmSyncResultIterator, TqdmSyncResultIteratorReturn, TqdmInnerIterator, TqdmIteratorResultSync, TqdmAsyncResultIterator, TqdmNumericIterator, TqdmIteratorResultAsync, TqdmWriteStream, } from './supply'; import {getTermColor, getTermColorReset} from './term'; import { formatTimeDelta, handleUnit, hasLength, isAsyncIterable, isIterable, isIterator, pluralService, scaleUnit, } from './utils'; export { ITqdmAsyncIteratorContainer, ITqdmSyncIteratorContainer, ITqdmProgress, TqdmInput, TqdmOptions, TqdmUnitTable, TqdmUnitOption, } from './base-types'; export { TqdmAsyncResultIterator, TqdmSyncResultIterator, } from './supply'; const defaultOptions: Required<TqdmOptions> = { description: '', maxColWidth: -1, progressBraces: ['|', '|'], progressSymbol: '█', progressColor: '', unit: 'it', unitScale: false, initial: 0, total: -1, step: 1, stream: process.stderr, minInterval: 50, forceTerminal: false, }; export function tqdm<TInput extends TqdmInput>(input: TInput, opts: TqdmOptions = {}): Tqdm<TInput> { return new Tqdm(input, opts); } export class Tqdm<TInput extends TqdmInput> implements Iterable<TqdmItem<TInput>>, AsyncIterable<TqdmItem<TInput>>, ITqdmSyncIteratorContainer<TqdmItem<TInput>>, ITqdmAsyncIteratorContainer<TqdmItem<TInput>> { private readonly iterator: TqdmInnerIterator<TqdmItem<TInput>>; private readonly progress: ITqdmProgress; constructor(private readonly input: TInput, options: TqdmOptions = {}) { if (typeof this.input == 'number') { this.iterator = new TqdmNumericIterator(this.input) as TqdmInnerIterator<TqdmItem<TInput>>; } else if (isIterable(this.input)) { this.iterator = this.input[Symbol.iterator]() as TqdmInnerIterator<TqdmItem<TInput>>; } else if (isAsyncIterable(this.input)) { this.iterator = this.input[Symbol.asyncIterator]() as TqdmInnerIterator<TqdmItem<TInput>>; } else if (isIterator(this.input)) { this.iterator = this.input as TqdmInnerIterator<TqdmItem<TInput>>; } else { throw new Error('Unknown TQDM input type'); } if (options.total === undefined || options.total < 0) { if (hasLength(this.input)) { options.total = this.input.length; } else if (typeof this.input == 'number') { options.total = this.input; } } this.progress = new TqdmProgress(options); this.progress.render(); } [Symbol.iterator](): TqdmSyncResultIteratorReturn<TInput> { return new TqdmSyncResultIterator(this) as TqdmSyncResultIteratorReturn<TInput>; } [Symbol.asyncIterator](): TqdmAsyncResultIteratorReturn<TInput> { return new TqdmAsyncResultIterator(this) as TqdmAsyncResultIteratorReturn<TInput>; } nextSync(): TqdmIteratorResultSync<TInput> { const res = this.iterator.next(); if (res instanceof Promise) { throw new Error('Async value in sync iterator'); } if (res.done) { this.progress.close(); } else { this.progress.update(); } return res as TqdmIteratorResultSync<TInput>; } nextAsync(): TqdmIteratorResultAsync<TInput> { const pRes = this.iterator.next(); if (!(pRes instanceof Promise)) { throw new Error('Sync value in async iterator'); } return new Promise((resolve, reject) => { pRes.then((res) => { if (res.done) { this.progress.close(); } else { this.progress.update(); } resolve(res); }).catch(reject); }) as TqdmIteratorResultAsync<TInput>; } } export class TqdmProgress implements ITqdmProgress { private readonly description: string; private readonly unit: TqdmUnitTable; private readonly maxColWidth: number; private readonly progressLeftBrace: string; private readonly progressRightBrace: string; private readonly progressSymbol: string; private readonly progressColor: string = ''; private readonly progressColorReset: string = ''; private readonly stream: TqdmWriteStream; private readonly minInterval: number; private readonly total: number; private readonly totalDigits: number = 0; private readonly initial: number; private readonly step: number; private readonly unitScale: boolean; private readonly unitDelimiter: string = ''; private readonly totalScaled: string = ''; private readonly haveCorrectTotal: boolean; private readonly startTime: number = Date.now(); private lastRenderTime: number = 0; private timeSpent: number = 0; private position: number; private itCounter: number = 0; private readonly num: (x: number) => string; public static withProgress<TResult>( fn: (progressBar: TqdmProgress) => TResult, options: TqdmOptions = {}, ): TResult { const progressBar = new TqdmProgress(options); try { progressBar.render(); return fn(progressBar); } finally { progressBar.close(); } } public static async withAsyncProgress<TResult>( fn: (progressBar: TqdmProgress) => Promise<TResult>, options: TqdmOptions = {}, ): Promise<TResult> { const progressBar = new TqdmProgress(options); try { progressBar.render(); return await fn(progressBar); } finally { progressBar.close(); } } constructor(options: TqdmOptions) { const fullOptions = { ...defaultOptions, ...options, }; this.description = fullOptions.description; this.unit = handleUnit(fullOptions.unit); this.maxColWidth = fullOptions.maxColWidth; [this.progressLeftBrace, this.progressRightBrace] = fullOptions.progressBraces; this.progressSymbol = fullOptions.progressSymbol; this.stream = new TqdmWriteStream(fullOptions.stream, fullOptions.forceTerminal); this.minInterval = fullOptions.minInterval; if (this.stream.isTty) { this.progressColor = getTermColor(fullOptions.progressColor); if (this.progressColor) { this.progressColorReset = getTermColorReset(); } } this.initial = this.position = fullOptions.initial; this.total = fullOptions.total; this.haveCorrectTotal = isFinite(this.total) && !isNaN(this.total) && this.total > 0; if (this.haveCorrectTotal) { this.totalDigits = Math.trunc(Math.log10(this.total)) + 1; } this.step = fullOptions.step; this.unitScale = fullOptions.unitScale; if (fullOptions.unitScale) { this.num = this.numScale; this.unitDelimiter = ' '; if (this.haveCorrectTotal) { this.totalScaled = scaleUnit(this.total); } } else { this.num = this.numNoScale; } } update(by: number = this.step) { this.position += by; ++this.itCounter; this.timeSpent = Date.now() - this.startTime; this.render(); } render(force = false) { const now = Date.now(); if (!force && now - this.lastRenderTime < this.minInterval) { return; } this.lastRenderTime = now; const left = this.generateLeft(); const right = this.generateRight(); const progressBar = this.generateProgressBar(left.length + right.length); this.stream.resetLine(); this.stream.write(`${left}${progressBar}${right}`); } close() { this.render(true); this.stream.finalize(); } private numScale(x: number) { return scaleUnit(x); } private numNoScale(x: number) { return x.toString(); } private doesPositionFitTotal() { return this.haveCorrectTotal && this.position <= this.total; } private generateLeft() { let countStr; if (this.doesPositionFitTotal()) { const percent = Math.round(Math.min(this.position, this.total) * 100 / this.total); countStr = percent == -1 ? '' : `${String(percent).padStart(3, ' ')}% `; } else { const unitKey = pluralService.select(this.position); countStr = `${this.num(this.position)}${this.unitDelimiter}${this.unit[unitKey]}`; } const descStr = this.description ? `${this.description}: ` : ''; return ` ${descStr}${countStr}`; } private generateRight() { const res: string[] = ['']; if (this.doesPositionFitTotal()) { if (this.unitScale) { res.push(`${this.num(this.position)}/${this.totalScaled}`); } else { const countStr = String(this.position).padStart(this.totalDigits, ' '); res.push(`${countStr}/${this.total}`); } } if (this.timeSpent) { const timePerIt = this.timeSpent / this.itCounter; const timePerItStr = (timePerIt / 1000).toFixed(3); const timeSpentStr = formatTimeDelta(this.timeSpent, true); let elapsedTime = ''; if (this.doesPositionFitTotal()) { const elapsedItems = this.step > 0 ? Math.max(0, this.total - this.position) : Math.max(0, this.total - (this.initial - this.position)); elapsedTime = '<' + formatTimeDelta(timePerIt * elapsedItems, true); } res.push(`[${timeSpentStr}${elapsedTime}, ${timePerItStr}s/${this.unit['one']}]`); } if (res.length > 1) { res.push(''); } return res.join(' '); } private generateProgressBar(reduceBy: number): string { if (!this.doesPositionFitTotal()) { return ''; } const ttyColumns = this.stream.columns; const baseColumns = this.maxColWidth > 0 ? Math.min(this.maxColWidth, ttyColumns) : ttyColumns; const columns = baseColumns - reduceBy - 2; if (columns < 4) { return ''; } const cnt = Math.trunc(columns * Math.min(this.position, this.total) / this.total); return [ this.progressLeftBrace, this.progressColor, this.progressSymbol.repeat(cnt), ' '.repeat(columns - cnt), this.progressColorReset, this.progressRightBrace, ].join(''); } }