UNPKG

baro

Version:

a command-line progress-bar

739 lines (536 loc) 22.2 kB
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 };