baro
Version:
a command-line progress-bar
739 lines (536 loc) • 22.2 kB
JavaScript
import { roundD2, round, constraint } from '@aryth/math';
import { STR, OBJ } from '@typen/enum-data-types';
import { valid } from '@typen/nullish';
import readline from 'readline';
const trailZero = num => {
if (!num) return '0';
const tx = '' + roundD2(num);
let i = tx.indexOf('.');
if (!~i) {
return tx + '.00';
}
let df = tx.length - i;
if (df === 3) {
return tx;
}
if (df === 2) {
return tx + '0';
}
if (df === 1) {
return tx + '00';
}
return tx;
};
const base3ToScale = (base3, dec) => {
if (base3 === 0) return 'B'; //
if (base3 === 1) return dec ? 'K' : 'KB'; // Kilo
if (base3 === 2) return dec ? 'M' : 'MB'; // Mega
if (base3 === 3) return dec ? 'G' : 'GB'; // Giga
if (base3 === 4) return dec ? 'T' : 'TB'; // Tera
if (base3 === 5) return dec ? 'P' : 'PB'; // Peta
if (base3 === 6) return dec ? 'E' : 'EB'; // Exa
if (base3 === 7) return dec ? 'Z' : 'ZB'; // Zetta
if (base3 === 8) return dec ? 'Y' : 'YB'; // Yotta
};
const DEFAULT_SENTENCE = 'progress [{bar}] {progress}% | ETA: {eta}s | {value}/{total}';
class Layout {
/**
* @param {object} [options]
* @param {number} [options.size] size of the progressbar in chars
* @param {string[]|string} [options.char]
* @param {string} [options.glue]
* @param {string} [options.sentence]
* @param {boolean} [options.autoZero = false] autoZero - false
* @param {function(State):string} [options.bar]
* @param {function(State):string} [options.degree]
* @param {function(State,object):string} [options.formatter]
*/
constructor(options) {
var _options$size, _options$glue, _options$autoZero, _options$sentence;
const char = typeof options.char === STR ? [options.char, ' '] : Array.isArray(options.char) ? options.char : ['=', '-'];
const [x, y] = char;
this.size = (_options$size = options.size) !== null && _options$size !== void 0 ? _options$size : 24;
this.chars = [x.repeat(this.size + 1), y.repeat(this.size + 1)];
this.glue = (_options$glue = options.glue) !== null && _options$glue !== void 0 ? _options$glue : '';
this.autoZero = (_options$autoZero = options.autoZero) !== null && _options$autoZero !== void 0 ? _options$autoZero : false; // autoZero - false
this.sentence = (_options$sentence = options.sentence) !== null && _options$sentence !== void 0 ? _options$sentence : DEFAULT_SENTENCE;
if (options.bar) this.bar = options.bar.bind(this);
if (options.degree) this.degree = options.degree.bind(this);
if (options.formatter) this.formatter = options.formatter.bind(this);
}
static build(options) {
return new Layout(options);
}
loadFormatter(formatter) {
this.formatter = formatter.bind(this);
return this;
}
bar(state) {
const {
progress
} = state;
const {
chars,
glue,
size
} = this;
const lenX = round(progress * size),
lenY = size - lenX;
const [x, y] = chars; // generate bar string by stripping the pre-rendered strings
return x.slice(0, lenX) + glue + y.slice(0, lenY);
}
degree(state) {
let {
value,
total
} = state;
const {
base3 = true,
decimal = true
} = this;
if (!base3) return `${round(value)}/${total}`;
const thousand = decimal ? 1000 : 1024;
let base3Level = 0;
while (total > thousand) {
// base3Level <= base3
total /= thousand;
value /= thousand;
base3Level++;
}
const totalText = trailZero(total);
const valueText = trailZero(value).padStart(totalText.length); // return { value: valueText, total: totalText, scale: base3ToScale(base3, dec) }
return `${valueText}/${totalText} ${base3ToScale(base3Level, decimal)}`;
}
/**
*
* @param {State|object} state
* @returns {string}
*/
formatter(state) {
var _this$sentence;
return (_this$sentence = this.sentence) === null || _this$sentence === void 0 ? void 0 : _this$sentence.replace(/\{(\w+)\}/g, (match, key) => {
if (key === 'bar') return this.bar(state);
if (key === 'degree') return this.degree(state);
return key in state ? state[key] : match;
});
}
}
class Config {
/**
*
* @param {object} config
*
* @param {WriteStream} [config.stream = process.stderr] the output stream to write on
* @param {number} [config.fps = 12] the max update rate in fps (redraw will only triggered on value change)
* @param {Terminal|any} [config.terminal = null] external terminal provided ?
* @param {boolean} [config.autoClear = false] clear on finish ?
* @param {boolean} [config.autoStop = false] stop on finish ?
* @param {boolean} [config.hideCursor = false] hide the cursor ?
* @param {boolean} [config.lineWrap = false] allow or disable setLineWrap ?
* @param {string} [config.sentence = DEFAULT_FORMAT] the bar sentence
* @param {function} [config.formatTime = null] external time-sentence provided ?
* @param {function} [config.formatValue = null] external value-sentence provided ?
* @param {function} [config.formatBar = null] external bar-sentence provided ?
* @param {boolean} [config.syncUpdate = true] allow synchronous updates ?
* @param {boolean} [config.noTTYOutput = false] noTTY mode
* @param {number} [config.notTTYSchedule = 2000] schedule - 2s
* @param {boolean} [config.forceRedraw = false] force bar redraw even if progress did not change
*
* @param {object} [config.eta] eta config
* @param {boolean} [config.eta.on = false] switch to turn on eta
* @param {number} [config.eta.capacity = 10] the number of results to average ETA over
* @param {boolean} [config.eta.autoUpdate = false] automatic eta updates based on fps
* @returns {Config}
*/
constructor(config) {
var _config$fps, _config$stream, _config$eta$capacity, _config$eta, _config$eta$autoUpdat, _config$eta2, _config$terminal, _config$autoClear, _config$autoStop, _config$hideCursor, _config$lineWrap, _config$syncUpdate, _config$noTTYOutput, _config$notTTYSchedul, _config$forceRedraw;
// merge layout
// the max update rate in fps (redraw will only triggered on value change)
this.throttle = 1000 / ((_config$fps = config.fps) !== null && _config$fps !== void 0 ? _config$fps : 10); // the output stream to write on
this.stream = (_config$stream = config.stream) !== null && _config$stream !== void 0 ? _config$stream : process.stderr;
this.eta = config.eta ? {
capacity: (_config$eta$capacity = (_config$eta = config.eta) === null || _config$eta === void 0 ? void 0 : _config$eta.capacity) !== null && _config$eta$capacity !== void 0 ? _config$eta$capacity : 10,
// the number of results to average ETA over
autoUpdate: (_config$eta$autoUpdat = (_config$eta2 = config.eta) === null || _config$eta2 === void 0 ? void 0 : _config$eta2.autoUpdate) !== null && _config$eta$autoUpdat !== void 0 ? _config$eta$autoUpdat : false // automatic eta updates based on fps
} : null;
this.terminal = (_config$terminal = config.terminal) !== null && _config$terminal !== void 0 ? _config$terminal : null; // external terminal provided ?
this.autoClear = (_config$autoClear = config.autoClear) !== null && _config$autoClear !== void 0 ? _config$autoClear : false; // clear on finish ?
this.autoStop = (_config$autoStop = config.autoStop) !== null && _config$autoStop !== void 0 ? _config$autoStop : false; // stop on finish ?
this.hideCursor = (_config$hideCursor = config.hideCursor) !== null && _config$hideCursor !== void 0 ? _config$hideCursor : false; // hide the cursor ?
this.lineWrap = (_config$lineWrap = config.lineWrap) !== null && _config$lineWrap !== void 0 ? _config$lineWrap : false; // disable setLineWrap ?
this.syncUpdate = (_config$syncUpdate = config.syncUpdate) !== null && _config$syncUpdate !== void 0 ? _config$syncUpdate : true; // allow synchronous updates ?
this.noTTYOutput = (_config$noTTYOutput = config.noTTYOutput) !== null && _config$noTTYOutput !== void 0 ? _config$noTTYOutput : false; // noTTY mode
this.notTTYSchedule = (_config$notTTYSchedul = config.notTTYSchedule) !== null && _config$notTTYSchedul !== void 0 ? _config$notTTYSchedul : 2000; // schedule - 2s
this.forceRedraw = (_config$forceRedraw = config.forceRedraw) !== null && _config$forceRedraw !== void 0 ? _config$forceRedraw : false; // force bar redraw even if progress did not change
return this;
}
static build(config) {
return new Config(config);
}
}
function _defineProperty(obj, key, value) {
if (key in obj) {
Object.defineProperty(obj, key, {
value: value,
enumerable: true,
configurable: true,
writable: true
});
} else {
obj[key] = value;
}
return obj;
}
function _classPrivateMethodGet(receiver, privateSet, fn) {
if (!privateSet.has(receiver)) {
throw new TypeError("attempted to get private field on non-instance");
}
return fn;
}
function _checkPrivateRedeclaration(obj, privateCollection) {
if (privateCollection.has(obj)) {
throw new TypeError("Cannot initialize the same private elements twice on an object");
}
}
function _classPrivateMethodInitSpec(obj, privateSet) {
_checkPrivateRedeclaration(obj, privateSet);
privateSet.add(obj);
}
// ETA calculation
class ETA {
constructor(capacity, initTime, initValue) {
// size of eta buffer
this.capacity = capacity || 100; // eta buffer with initial values
this.valueSeries = [initValue];
this.timeSeries = [initTime]; // eta time value
this.eta = '0';
} // add new values to calculation buffer
update(time, {
value,
total
}) {
this.valueSeries.push(value);
this.timeSeries.push(time); // trigger recalculation
this.calculate(total - value);
} // fetch estimated time
get value() {
return this.eta;
} // eta calculation - request number of remaining events
calculate(remaining) {
// get number of samples in eta buffer
const consumed = this.valueSeries.length;
const gap = Math.min(this.capacity, consumed);
const dValue = this.valueSeries[consumed - 1] - this.valueSeries[consumed - gap];
const dTime = this.timeSeries[consumed - 1] - this.timeSeries[consumed - gap]; // get progress per ms
const marginalRate = dValue / dTime; // strip past elements
this.valueSeries = this.valueSeries.slice(-this.capacity);
this.timeSeries = this.timeSeries.slice(-this.capacity); // eq: vt_rate *x = total
const eta = Math.ceil(remaining / marginalRate / 1000); // check values
return this.eta = isNaN(eta) ? 'NULL' : !isFinite(eta) ? 'INF' // +/- Infinity --- NaN already handled
: eta > 1e7 ? 'INF' // > 10M s ? - set upper display limit ~115days (1e7/60/60/24)
: eta < 0 ? 0 // negative ?
: eta; // assign
}
}
class State {
/** @type {number} the current bar value */
/** @type {number} the end value of the bar */
/** @type {?number} start time (used for eta calculation) */
/** @type {?number} stop time (used for duration calculation) */
constructor(total, value, payload, eta) {
var _eta$capacity;
_defineProperty(this, "value", 0);
_defineProperty(this, "total", 100);
_defineProperty(this, "start", null);
_defineProperty(this, "end", null);
this.value = value !== null && value !== void 0 ? value : 0;
this.total = total !== null && total !== void 0 ? total : 100;
this.start = Date.now(); // store start time for duration+eta calculation
this.end = null; // reset stop time for 're-start' scenario (used for duration calculation)
this.calETA = eta ? new ETA((_eta$capacity = eta.capacity) !== null && _eta$capacity !== void 0 ? _eta$capacity : 64, this.start, this.value) : null; // initialize eta buffer
this.payload = payload !== null && payload !== void 0 ? payload : {};
return this;
}
static build(values) {
const {
total,
value,
payload,
eta
} = values;
return new State(total, value, payload, eta);
}
initialize(total, value, payload, eta) {
return Object.assign(this, new State(total, value, payload, eta));
}
get eta() {
return this.calETA.eta;
}
get reachLimit() {
return this.value >= this.total;
}
get progress() {
const progress = this.value / this.total;
return isNaN(progress) ? 0 // this.preset?.autoZero ? 0.0 : 1.0
: constraint(progress, 0, 1);
}
update(value, payload) {
var _this$calETA;
// if (payload) for (let key in payload) this[key] = payload[key]
this.value = value;
if (payload) this.payload = payload;
(_this$calETA = this.calETA) === null || _this$calETA === void 0 ? void 0 : _this$calETA.update(Date.now(), this); // add new value; recalculate eta
if (this.reachLimit) this.end = Date.now();
return this;
}
stop(value, payload) {
this.end = Date.now();
if (valid(value)) this.value = value;
if (payload) this.payload = payload;
}
get elapsed() {
var _this$end;
return round((((_this$end = this.end) !== null && _this$end !== void 0 ? _this$end : Date.now()) - this.start) / 1000);
}
get percent() {
return ~~(this.progress * 100);
}
}
class Escape {
constructor(conf) {
var _conf$ctx, _conf$arg, _conf$instant;
this.ctx = (_conf$ctx = conf.ctx) !== null && _conf$ctx !== void 0 ? _conf$ctx : {};
this.arg = (_conf$arg = conf.arg) !== null && _conf$arg !== void 0 ? _conf$arg : null;
this.fn = conf.fn.bind(this.ctx, this.arg);
this.instant = (_conf$instant = conf.instant) !== null && _conf$instant !== void 0 ? _conf$instant : true;
this.timer = null;
this.logs = [];
}
static build(conf) {
return new Escape(conf);
}
get active() {
return valid(this.timer);
}
loop(ms) {
if (typeof this.timer === OBJ) this.stop();
if (!this.fn) return void 0;
const func = () => {
this.fn();
this.logs.push(this.arg.map(state => state.eta));
};
if (this.instant) func();
this.timer = setInterval(func, ms);
}
stop() {
clearTimeout(this.timer);
return this.timer = null;
}
}
class Terminal {
/** @type {boolean} line wrapping enabled */
/** @type {number} current, relative y position */
/**
*
* @param {Config|object} configs
* @param {node::WriteStream} configs.stream
* @param {boolean} [configs.lineWrap]
* @param {number} [configs.dy]
*
*/
constructor(configs) {
var _configs$lineWrap, _configs$dy;
_defineProperty(this, "stream", null);
_defineProperty(this, "lineWrap", true);
_defineProperty(this, "dy", 0);
this.stream = configs.stream;
this.lineWrap = (_configs$lineWrap = configs.lineWrap) !== null && _configs$lineWrap !== void 0 ? _configs$lineWrap : true;
this.dy = (_configs$dy = configs.dy) !== null && _configs$dy !== void 0 ? _configs$dy : 0;
} // tty environment ?
get isTTY() {
return this.stream.isTTY;
} // get terminal width
get width() {
var _this$stream$columns;
return (_this$stream$columns = this.stream.columns) !== null && _this$stream$columns !== void 0 ? _this$stream$columns : this.stream.isTTY ? 80 : 200; // set max width to 80 in tty-mode and 200 in noTTY-mode
} // write content to output stream
// @TODO use string-width to strip length
write(tx) {
this.stream.write(tx); // this.stream.write(this.lineWrap ? tx.slice(0, this.width) : tx)
}
cleanWrite(tx) {
this.cursorTo(0, null); // set cursor to start of line
this.stream.write(tx); // write output
this.clearRight(); // clear to the right from cursor
} // save cursor position + settings
saveCursor() {
if (!this.isTTY) return void 0;
this.stream.write('\x1B7'); // save position
} // restore last cursor position + settings
restoreCursor() {
if (!this.isTTY) return void 0;
this.stream.write('\x1B8'); // restore cursor
} // show/hide cursor
showCursor(enabled) {
if (!this.isTTY) return void 0;
enabled ? this.stream.write('\x1B[?25h') : this.stream.write('\x1B[?25l');
} // change cursor position
cursorTo(x, y) {
if (!this.isTTY) return void 0;
readline.cursorTo(this.stream, x, y); // move cursor absolute
} // change relative cursor position
moveCursor(dx, dy) {
if (!this.isTTY) return void 0;
if (dy) this.dy += dy; // store current position
if (dx || dy) readline.moveCursor(this.stream, dx, dy); // move cursor relative
} // reset relative cursor
resetCursor() {
if (!this.isTTY) return void 0;
readline.moveCursor(this.stream, 0, -this.dy); // move cursor to initial line
readline.cursorTo(this.stream, 0, null); // first char
this.dy = 0; // reset counter
} // clear to the right from cursor
clearRight() {
if (!this.isTTY) return void 0;
readline.clearLine(this.stream, 1);
} // clear the full line
clearLine() {
if (!this.isTTY) return void 0;
readline.clearLine(this.stream, 0);
} // clear everything beyond the current line
clearDown() {
if (!this.isTTY) return void 0;
readline.clearScreenDown(this.stream);
} // add new line; increment counter
newline() {
this.stream.write('\n');
this.dy++;
} // control line wrapping
setLineWrap(enabled) {
if (!this.isTTY) return void 0;
this.lineWrap = enabled; // store state
enabled ? this.stream.write('\x1B[?7h') : this.stream.write('\x1B[?7l');
}
}
var _renderStates = /*#__PURE__*/new WeakSet();
class Baro {
/** @type {Config|object} store config */
/** @type {object} payload data */
/** @type {Terminal|object} store terminal instance */
/** @type {?string} last drawn string - only render on change! */
/** @type {number} last update time */
/** @type {boolean} progress bar active ? */
/** @type {function} use default formatter or custom one ? */
/** @type {State} */
/**
*
* @param {Config} config
* @param {Layout} layout
*/
constructor(config, layout) {
var _this$config$terminal;
_classPrivateMethodInitSpec(this, _renderStates);
_defineProperty(this, "config", void 0);
_defineProperty(this, "payload", {});
_defineProperty(this, "terminal", void 0);
_defineProperty(this, "phrase", null);
_defineProperty(this, "prev", void 0);
_defineProperty(this, "active", false);
_defineProperty(this, "formatter", void 0);
_defineProperty(this, "state", void 0);
this.config = config;
this.layout = layout;
this.states = [];
this.escape = Escape.build({
fn: _classPrivateMethodGet(this, _renderStates, _renderStates2),
ctx: this,
arg: this.states
});
this.terminal = (_this$config$terminal = this.config.terminal) !== null && _this$config$terminal !== void 0 ? _this$config$terminal : new Terminal(this.config);
}
static build(config, layout) {
config = Config.build(config);
layout = Layout.build(layout);
return new Baro(config, layout);
}
get progress() {
return this.state.progress;
}
get forceRedraw() {
return this.config.forceRedraw || this.config.noTTYOutput && !this.terminal.isTTY; // force redraw in noTTY-mode!
}
get noTTY() {
return this.config.noTTYOutput && !this.terminal.isTTY;
}
boot() {
if (this.config.hideCursor) this.terminal.showCursor(false); // hide the cursor ?
if (!this.config.lineWrap) this.terminal.setLineWrap(false); // disable line wrapping ?
this.escape.loop(this.config.throttle); // initialize update timer
this.active = true;
} // add a new bar to the stack
/**
*
* @param total
* @param value
* @param payload
* @returns {State}
*/
create(total, value, payload) {
// progress updates are only visible in TTY mode!
if (this.noTTY) return void 0;
const state = new State(total, value, payload, true);
state.last = Number.NEGATIVE_INFINITY;
this.states.push(state);
if (!this.escape.active && this.states.length) this.boot();
return state;
} // remove a bar from the stack
remove(state) {
const index = this.states.indexOf(state); // find element
if (index < 0) {
return false;
} // element found ?
this.states.splice(index, 1); // remove element
this.terminal.newline();
this.terminal.clearDown();
return true;
}
stop() {
this.escape.stop(); // stop timer
this.active = false; // set flag
if (this.config.hideCursor) {
this.terminal.showCursor(true);
} // cursor hidden ?
if (!this.config.lineWrap) {
this.terminal.setLineWrap(true);
} // re-enable line wrapping ?
if (this.config.autoClear) {
this.terminal.resetCursor(); // reset cursor
this.terminal.clearDown();
} // clear all bars or show final progress
else {
for (let state of this.states) {
state.stop();
}
_classPrivateMethodGet(this, _renderStates, _renderStates2).call(this, this.states);
}
}
}
function _renderStates2(states) {
this.terminal.resetCursor(); // reset cursor
for (let i = 0, hi = states.length; i < hi; i++) {
const state = states[i]; // update each bar
if (this.forceRedraw || state.value !== state.last) {
this.terminal.cleanWrite(this.layout.formatter(state));
} // string updated, only trigger redraw on change
this.terminal.newline();
state.last = state.value;
}
if (this.noTTY) {
this.terminal.newline();
this.terminal.newline();
} // add new line in noTTY mode
if (this.config.autoStop && states.every(state => state.reachLimit)) {
this.stop();
} // stop if autoStop and all bars stopped
}
export { Baro, Config, ETA, Escape, Layout, State };