baro
Version:
a command-line progress-bar
742 lines (574 loc) • 19.9 kB
JavaScript
import { roundD2, round, constraint } from '@aryth/math';
import { STR } from '@typen/enum-data-types';
import { valid } from '@typen/nullish';
import { setInterval } from 'timers/promises';
import { IO as IO$1 } from '@arpel/backend';
import { cursor, clear, decset } from '@arpel/escape';
import { LF } from '@pres/enum-control-chars';
import * as rl from 'readline';
const trailZero = num => {
if (!num) return '0';
const tx = '' + roundD2(num);
let i = tx.indexOf('.');
if (!~i) {
return tx + '.00';
}
const trail = tx.length - i;
if (trail === 3) {
return tx;
}
if (trail === 2) {
return tx + '0';
}
if (trail === 1) {
return tx + '00';
}
return tx;
};
const pad3 = tx => {
const len = tx.length;
if (len === 3) {
return tx;
}
if (len === 2) {
return ' ' + tx;
}
if (len === 1) {
return ' ' + tx;
}
if (len === 0) {
return ' ';
}
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.format]
*/
constructor(options) {
const char = typeof options.char === STR ? [options.char, ' '] : Array.isArray(options.char) ? options.char : ['=', '-'];
const [x, y] = char;
this.size = options.size ?? 24;
this.chars = [x.repeat(this.size + 1), y.repeat(this.size + 1)];
this.glue = options.glue ?? '';
this.autoZero = options.autoZero ?? false; // autoZero - false
this.sentence = options.sentence ?? DEFAULT_SENTENCE;
if (options.bar) this.bar = options.bar.bind(this);
if (options.degree) this.degree = options.degree.bind(this);
if (options.format) this.format = options.format.bind(this);
}
static build(options) {
return new Layout(options);
}
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);
}
get fullBar() {
return this.chars[0].slice(0, this.size);
}
get zeroBar() {
return this.chars[1].slice(0, this.size);
}
degree(state) {
let {
value,
total
} = state;
const {
base3 = true,
decimal = false
} = 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 t = trailZero(total);
const v = trailZero(value).padStart(t.length); // return { value: valueText, total: totalText, scale: base3ToScale(base3, dec) }
return `${v}/${t} ${base3ToScale(base3Level, decimal)}`;
}
/**
*
* @param {State|object} state
* @returns {string}
*/
format(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);
if (key in state) return state[key];
return match;
});
}
}
class Config {
/**
*
* @param {object} config
*
* @param {ReadStream} [config.input = process.stdin] the output stream to read from
* @param {WriteStream} [config.output = process.stdout] 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 {IO|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$eta, _config$eta2;
// merge layout
// the max update rate in fps (redraw will only triggered on value change)
this.throttle = 1000 / (config.fps ?? 10); // the output stream to write on
this.output = config.output ?? process.stdout;
this.input = config.input ?? process.stdin;
this.eta = config.eta ? {
capacity: ((_config$eta = config.eta) === null || _config$eta === void 0 ? void 0 : _config$eta.capacity) ?? 10,
// the number of results to average ETA over
autoUpdate: ((_config$eta2 = config.eta) === null || _config$eta2 === void 0 ? void 0 : _config$eta2.autoUpdate) ?? false // automatic eta updates based on fps
} : null;
this.terminal = config.terminal ?? null; // external terminal provided ?
this.autoClear = config.autoClear ?? false; // clear on finish ?
this.autoStop = config.autoStop ?? false; // stop on finish ?
this.hideCursor = config.hideCursor ?? true; // hide the cursor ?
this.lineWrap = config.lineWrap ?? false; // disable setLineWrap ?
this.syncUpdate = config.syncUpdate ?? true; // allow synchronous updates ?
this.noTTYOutput = config.noTTYOutput ?? false; // noTTY mode
this.notTTYSchedule = config.notTTYSchedule ?? 2000; // schedule - 2s
this.forceRedraw = config.forceRedraw ?? false; // force bar redraw even if progress did not change
return this;
}
static build(config) {
return new Config(config);
}
}
// ETA: estimated time to completion
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.estimate = '0';
} // add new values to calculation buffer
update({
now,
value,
total
}) {
this.valueSeries.push(value);
this.timeSeries.push(now ?? Date.now());
this.calculate(total - value);
} // eta calculation - request number of remaining events
calculate(remaining) {
const len = this.valueSeries.length,
// get number of samples in eta buffer
cap = this.capacity,
hi = len - 1,
lo = len > cap ? len - cap : 0; // consumed-Math.min(cap,len)
const dValue = this.valueSeries[hi] - this.valueSeries[lo],
dTime = this.timeSeries[hi] - this.timeSeries[lo],
rate = dValue / dTime; // get progress per ms
if (len > cap) {
this.valueSeries = this.valueSeries.slice(-cap);
this.timeSeries = this.timeSeries.slice(-cap);
} // strip past elements
const eta = Math.ceil(remaining / (rate * 1000));
return this.estimate = 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: 0
: eta; // assign
}
}
class State {
constructor(data) {
this.value = data.value ?? 0;
this.total = data.total ?? 100;
this.start = data.start ?? Date.now(); // store start time for duration+eta calculation
this.end = data.end ?? null; // reset stop time for 're-start' scenario (used for duration calculation)
this.calETA = data.eta ? new ETA(data.eta.capacity ?? 64, this.start, this.value) : null; // initialize eta buffer
return this;
}
static build(data) {
return new State(data);
}
get eta() {
var _this$calETA;
return (_this$calETA = this.calETA) === null || _this$calETA === void 0 ? void 0 : _this$calETA.estimate;
}
get reachLimit() {
return this.value >= this.total;
}
get elapsed() {
return round(((this.end ?? Date.now()) - this.start) / 1000);
}
get percent() {
return pad3('' + ~~(this.progress * 100));
}
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) {
var _this$calETA2;
if (valid(value)) this.value = value;
this.now = Date.now();
(_this$calETA2 = this.calETA) === null || _this$calETA2 === void 0 ? void 0 : _this$calETA2.update(this); // add new value; recalculate eta
if (this.reachLimit) this.end = this.now;
return this;
}
stop(value) {
if (valid(value)) this.value = value;
this.end = Date.now();
return this;
}
}
class Escape {
constructor(conf) {
this.ctx = conf.ctx ?? {};
this.arg = conf.arg ?? null;
this.fn = conf.fn.bind(this.ctx, this.arg);
this.instant = conf.instant ?? true;
this.on = false;
}
static build(conf) {
return new Escape(conf);
}
get active() {
return this.on;
}
async loop(ms) {
this.on = true;
if (!this.fn) return void 0;
if (this.instant) this.fn();
for await (const _ of setInterval(ms)) {
if (!this.on) break;
await this.fn();
}
}
stop() {
return this.on = false;
}
}
class IO extends IO$1 {
/** @type {boolean} line wrapping enabled */
lineWrap = true;
/** @type {number} current, relative y position */
offset = 0;
/**
*
* @param {Config|object} configs
* @param {ReadStream} configs.input
* @param {WriteStream} configs.output
* @param {boolean} [configs.lineWrap]
* @param {number} [configs.offset]
*
*/
constructor(configs) {
super(configs);
this.offset = configs.offset ?? 0;
if (!this.isTTY) {
console.error('>> [baro:IO] stream is not tty. baro:IO functions may not work.');
}
}
static build(configs) {
return new IO(configs);
} // tty environment
get isTTY() {
return this.output.isTTY;
}
get excessive() {
return this.offset > this.height;
} // CSI n G CHA Cursor Horizontal Absolute Moves the cursor to column n (default 1).
// write output
// clear to the right from cursor
writeOff(text) {
this.output.write(cursor.charTo(0) + text + clear.RIGHT_TO_CURSOR);
}
saveCursor() {
this.output.write(cursor.SAVE);
} // save cursor position + settings
restoreCursor() {
this.output.write(cursor.RESTORE);
} // restore last cursor position + settings
showCursor(enabled) {
this.output.write(enabled ? cursor.SHOW : cursor.HIDE);
} // show/hide cursor
cursorTo(x, y) {
this.output.write(cursor.goto(x, y));
} // change cursor position: rl.cursorTo(this.output, x, y)
// change relative cursor position
moveCursor(dx, dy) {
if (dy) this.offset += dy; // store current position
if (dx || dy) rl.moveCursor(this.output, dx, dy); // move cursor relative
}
offsetLines(offset) {
if (offset === 0) return;
const cursorMovement = offset >= 0 ? cursor.down(offset) : cursor.up(-offset);
this.output.write(cursorMovement + cursor.charTo(0)); // rl.moveCursor(this.output, -this.width, offset) // move cursor to initial line // rl.cursorTo(this.stream, 0, null) // first char
} // reset relative cursor
resetCursor() {
this.offsetLines(-this.offset); // rl.moveCursor(this.output, -this.width, this.offset) // move cursor to initial line // rl.cursorTo(this.stream, 0, null) // first char
this.offset = 0; // reset counter
}
clearRight() {
rl.clearLine(this.output, 1);
} // clear to the right from cursor
clearLine() {
rl.clearLine(this.output, 0);
} // clear the full line
clearDown() {
rl.clearScreenDown(this.output);
} // clear everything beyond the current line
// add new line; increment counter
nextLine() {
this.output.write(LF);
this.offset++;
} // create new page, equivalent to Ctrl+L clear screen
nextPage() {
this.output.write(clear.ENTIRE_SCREEN + cursor.goto(0, 0)); // this.offset = 0
} // store state + control line wrapping
setLineWrap(enabled) {
this.output.write(enabled ? decset.WRAP_ON : decset.WRAP_OFF);
}
}
class Baro {
config;
format;
states = [];
escape = Escape.build({
fn: this.#renderStates,
ctx: this,
arg: this.states
});
#locker = null;
/**
*
* @param {Config} config
* @param {Layout} layout
*/
constructor(config, layout) {
this.config = config;
this.layout = layout;
this.io = IO.build(this.config);
}
static build(config, layout) {
config = Config.build(config);
layout = Layout.build(layout);
return new Baro(config, layout);
}
get active() {
return this.escape.active;
}
get forceRedraw() {
return this.config.forceRedraw || this.config.noTTYOutput && !this.io.isTTY; // force redraw in noTTY-mode!
}
async start() {
const {
io
} = this;
io.input.resume();
if (this.config.hideCursor) io.showCursor(false); // hide the cursor ?
if (!this.config.lineWrap) io.setLineWrap(false); // disable line wrapping ?
// this.io.output.write('\f')
// const height = this.io.height
// this.io.output.write(cursor.nextLine(height) + cursor.prevLine(height))
// this.io.output.write(scroll.down(height))
// const [ x, y ] = await io.asyncCursorPos()
// console.log('x', x, 'y', y, 'offset', io.offset, 'states', this.states.length, 'height', io.height)
// if (x + this.states.length >= io.height) {
// io.output.write('\f')
// // io.nextPage()
// }
// WARNING: intentionally call loop without await
Promise.resolve().then(() => this.escape.loop(this.config.throttle)); // initialize update timer
}
/**
* add a new bar to the stack
* @param {State|object} state // const state = new State(total, value, this.config.eta)
* @returns {State|object}
*/
async append(state) {
if (this.#locker) await this.#locker; // console.debug('>>', state.agent, 'waiting for occupy')
const taskPromise = Promise.resolve().then(async () => {
state.last = Number.NEGATIVE_INFINITY;
this.states.push(state);
if (!this.escape.active && this.states.length) await this.start();
});
this.#locker = taskPromise.then(() => this.#locker = null);
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.io.nextLine();
this.io.clearDown();
return true;
}
async stop() {
this.escape.stop(); // stop timer
if (this.config.hideCursor) {
this.io.showCursor(true);
} // cursor hidden ?
if (!this.config.lineWrap) {
this.io.setLineWrap(true);
} // re-enable line wrapping ?
if (this.config.autoClear) {
this.io.resetCursor(); // reset cursor
this.io.clearDown();
} // clear all bars or show final progress
else {
// for (let state of this.states) { state.stop() }
await this.#renderStates(this.states);
}
this.io.input.pause();
}
async #renderStates(states) {
const {
io
} = this;
const height = io.height - 1; // const [ x, y ] = await io.asyncCursorPos()
io.offsetLines(-Math.min(io.offset, height)); // reset cursor
io.offset = 0;
if (height) {
for (const state of states.slice(-height)) {
if (this.forceRedraw || state.value !== state.last) {
// `CURSOR (${x}, ${y}) OFFSET (${io.offset}) TERM (${io.size}) ` +
io.writeOff(this.layout.format(state));
state.last = state.value;
}
io.nextLine();
}
}
if (this.config.autoStop && states.every(state => state.reachLimit)) {
await this.stop();
} // stop if autoStop and all bars stopped
}
}
class Spin {
max;
value;
width;
step;
constructor(max, width, step) {
this.max = max;
this.width = width;
this.step = step;
this.value = 0;
}
static build(max, width, step) {
return new Spin(max, width, step);
}
next() {
this.value += this.step;
if (this.value >= this.max) this.value -= this.max;
return this;
}
get sequel() {
let next = this.value + this.width;
if (next > this.max) next -= this.max;
return next;
}
get record() {
const {
value,
sequel
} = this;
if (value <= 0 || value >= this.max) {
const x = this.width;
const y = this.max - this.width;
return [0, 0, x, y];
} else {
if (value < sequel) {
const x = value - 1;
const y = this.width;
const z = this.max - this.width - value + 1;
return [0, x, y, z];
} else {
const x = sequel;
const y = value - sequel - 1;
const z = this.max - value + 1;
return [x, y, z, 0];
}
}
}
renderBar([bar, spc]) {
const {
value,
sequel
} = this;
if (value <= 0 || value >= this.max) {
const x = this.width;
const y = this.max - this.width;
return bar.slice(0, x) + spc.slice(0, y);
} else {
if (value < sequel) {
const x = value;
const y = this.width;
const z = this.max - this.width - value;
return spc.slice(0, x) + bar.slice(0, y) + spc.slice(0, z);
} else {
const x = sequel;
const y = value - sequel;
const z = this.max - value;
return bar.slice(0, x) + spc.slice(0, y) + bar.slice(0, z);
}
}
}
}
const CHARSET_SHADE = [`█`, `░`]; // \u2588 \u2591
const CHARSET_RECT = [`■`, ` `]; // \u25A0
const CHARSET_LEGACY = [`=`, `-`];
export { Baro, CHARSET_LEGACY, CHARSET_RECT, CHARSET_SHADE, Config, ETA, Escape, Layout, Spin, State };