oe-ascii-progress
Version:
Ascii progress-bar(s) in the terminal.
641 lines (486 loc) • 13.3 kB
JavaScript
var ansi = require('ansi.js');
var endWith = require('end-with');
var startWith = require('start-with');
var getCurosrPos = require('get-cursor-position');
var newlineEvent = require('on-new-line');
var stream = process.stdout;
stream.rows = stream.rows || 40;
stream.columns = stream.columns || 80;
var placeholder = '\uFFFC';
var rendering = false;
var instances = [];
function beginUpdate() {
rendering = true;
}
function endUpdate() {
rendering = false;
}
function isUpdating() {
return rendering === true;
}
newlineEvent(stream);
stream.on('before:newlines', function (count) {
if (isUpdating() || instances.length === 0) {
return;
}
var current = getCurosrPos.sync();
// did not reach the end, the screen need not scroll up
if (!current || !current.row || current.row < stream.rows) {
return;
}
var minRow = 1;
var cursor = instances[0].cursor;
beginUpdate();
instances.forEach(function (instance) {
if (instance.rendered && (!instance.completed || instance.tough)) {
// clear the rendered bar
instance.clear();
instance.origin.row = Math.max(minRow, instance.origin.row - count);
minRow += instance.rows;
} else if (instance.rendered
&& instance.completed
&& !instance.tough
&& !instance.archived
&& !instance.clean) {
instance.clear();
instance.origin.row = -instance.rows;
instance.colorize(instance.output);
instance.archived = true;
}
});
// append empty row for the new lines, the screen will scroll up,
// then we can move the bars to their's new position.
cursor
.moveTo(current.row, current.col)
.write(repeatChar(count, '\n'));
instances.forEach(function (instance) {
if (instance.rendered && (!instance.completed || instance.tough)) {
instance.colorize(instance.output);
}
});
cursor.moveTo(current.row - count, current.col);
endUpdate();
});
function ProgressBar(options) {
options = options || {};
this.cursor = ansi(stream);
this.total = options.total || 100;
this.current = options.current || 0;
this.width = options.width || 60;
if (typeof this.width === 'string') {
if (endWith(this.width, '%')) {
this.width = parseFloat(this.width) / 100 % 1;
} else {
this.width = parseFloat(this.width);
}
}
this.tough = !!options.tough;
this.clean = !!options.clean;
this.chars = {
blank: options.blank || '—',
filled: options.filled || '▇'
};
// callback on completed
this.callback = options.callback;
this.setSchema(options.schema);
this.snoop();
instances.push(this);
}
// exports
// -------
module.exports = ProgressBar;
// proto
// -----
ProgressBar.prototype.setSchema = function (schema, refresh) {
this.schema = schema || ' [:bar] :current/:total :percent :elapseds :etas';
if (refresh) {
this.compile(refresh);
}
};
ProgressBar.prototype.tick = function (delta, tokens) {
var type = typeof delta;
if (type === 'object') {
tokens = delta;
delta = 1;
} else if (type === 'undefined') {
delta = 1;
} else {
delta = parseFloat(delta);
if (isNaN(delta) || !isFinite(delta)) {
delta = 1;
}
}
if (this.completed && delta >= 0) {
return;
}
if (!this.start) {
this.start = new Date;
}
this.current += delta;
this.compile(tokens);
this.snoop();
};
ProgressBar.prototype.update = function (ratio, tokens) {
var completed = Math.floor(ratio * this.total);
var delta = completed - this.current;
this.tick(delta, tokens);
};
ProgressBar.prototype.compile = function (tokens) {
var ratio = this.current / this.total;
ratio = Math.min(Math.max(ratio, 0), 1);
var chars = this.chars;
var schema = this.schema;
var percent = ratio * 100;
var elapsed = new Date - this.start;
var eta;
if (this.current <= 0) {
eta = '-';
} else {
eta = percent === 100 ? 0 : elapsed * this.total / this.current;
eta = formatTime(eta);
}
var output = schema
.replace(/:total/g, this.total)
.replace(/:current/g, this.current)
.replace(/:elapsed/g, formatTime(elapsed))
.replace(/:eta/g, eta)
.replace(/:percent/g, toFixed(percent, 0) + '%');
if (tokens && typeof tokens === 'object') {
for (var key in tokens) {
if (tokens.hasOwnProperty(key)) {
output = output.replace(new RegExp(':' + key, 'g'), ('' + tokens[key]) || placeholder);
}
}
}
var raw = bleach(output);
var cols = process.stdout.columns;
var width = this.width;
width = width < 1 ? cols * width : width;
width = Math.min(width, Math.max(0, cols - bareLength(raw)));
var length = Math.round(width * ratio);
var filled = repeatChar(length, chars.filled);
var blank = repeatChar(width - length, chars.blank);
raw = combine(raw, filled, blank, true);
output = combine(output, filled, blank, false);
// without color and font styles
this.raw = raw;
// row count of the progress bar
this.rows = raw.split('\n').length;
this.render(output);
};
ProgressBar.prototype.render = function (output) {
if (this.output === output) {
return;
}
var current = getCurosrPos.sync();
if (!current) {
return;
}
beginUpdate();
this.savePos = current;
if (!this.origin) {
this.origin = current;
}
if (this.origin.row === stream.rows) {
this.cursor.write(repeatChar(this.rows, '\n'));
instances.forEach(function (instance) {
if (instance.origin) {
instance.origin.row -= this.rows;
}
}, this);
}
this.clear();
this.colorize(output);
// move the cursor to the current position.
if (this.rendered) {
this.cursor.moveTo(current.row, current.col);
}
this.output = output;
this.rendered = true;
endUpdate();
};
ProgressBar.prototype.colorize = function (output) {
var cursor = this.cursor;
var parts = output.split(/(\.[A-Za-z]+)/g);
var content = '';
var matches = [];
cursor.moveTo(this.origin.row, this.origin.col);
function write() {
//console.log(content)
//console.log(matches)
var hasFg = false;
var hasBg = false;
var gradient = null;
matches.forEach(function (match) {
if (match.method === 'gradient') {
gradient = match;
return;
}
var host = match.isBg
? cursor.bg : match.isFont
? cursor.font
: cursor.fg;
if (match.isBg) {
hasBg = true;
} else {
hasFg = true;
}
host[match.method]();
});
content = content.replace(new RegExp(placeholder, 'g'), '');
if (content) {
if (gradient) {
var color1 = gradient.color1;
var color2 = gradient.color2;
for (var i = 0, l = content.length; i < l; i++) {
var color = i === 0
? color1 : i === l - 1
? color2
: interpolate(color1, color2, (i + 1) / l);
cursor.fg.rgb(color.r, color.g, color.b);
cursor.write(content[i]);
cursor.fg.reset();
}
} else {
cursor.write(content);
}
}
// reset font style
matches.forEach(function (match) {
if (match.isFont) {
cursor.font['reset' + ucFirst(match.method)]();
}
});
// reset foreground
if (hasFg) {
cursor.fg.reset();
}
// reset background
if (hasBg) {
cursor.bg.reset();
}
matches = [];
content = '';
}
for (var i = 0, l = parts.length; i < l; i++) {
var part = parts[i];
var match = null;
if (!part) {
continue;
}
if (startWith(part, '.')) {
if (part === '.gradient') {
if (parts[i + 1]) {
match = parseGradient(parts[i + 1]);
parts[i + 1] = parts[i + 1].replace(/^\((.+),(.+)\)/, '');
}
} else {
match = parseMethod(cursor, part);
}
}
if (match) {
if (match.suffix) {
if (i < l - 1) {
parts[i + 1] += match.suffix;
} else {
// the last one
cursor.write(match.suffix);
}
}
matches.push(match);
} else {
if (matches.length) {
write();
}
content += part;
}
}
write();
cursor.write('\n');
};
ProgressBar.prototype.clear = function () {
if (this.output) {
this.cursor.moveTo(this.origin.row, this.origin.col);
for (var i = 0; i < this.rows; i++) {
this.cursor
.eraseLine()
.moveDown();
}
this.cursor.moveTo(this.origin.row, this.origin.col);
}
};
ProgressBar.prototype.snoop = function () {
this.completed = this.current >= this.total;
if (this.completed) {
this.terminate();
}
return this.completed;
};
ProgressBar.prototype.terminate = function () {
if (this.clean && this.rendered) {
this.clear();
//var lines = this.raw.split('\n');
//for (var i = 0; i < this.rows; i++) {
// this.cursor
// .deleteLine()
// .moveDown();
//}
}
this.callback && this.callback(this);
this.cursor.moveTo(this.savePos.row, this.savePos.col);
};
// helpers
// -------
function toFixed(value, precision) {
var power = Math.pow(10, precision);
return (Math.round(value * power) / power).toFixed(precision);
}
function formatTime(ms) {
return isNaN(ms) || !isFinite(ms)
? '0.0'
: toFixed(ms / 1000, 1);
}
function lcFirst(str) {
return str.charAt(0).toLowerCase() + str.substring(1);
}
function ucFirst(str) {
return str.charAt(0).toUpperCase() + str.substring(1);
}
function repeatChar(count, char) {
return new Array(count + 1).join(char);
}
function parseMethod(cursor, str) {
str = str.substr(1);
return parseColor(cursor, str)
|| parseFont(cursor, str)
|| parseGradient(str);
}
function parseColor(cursor, str) {
var match = str.match(/^(bgR|r)ed/)
|| str.match(/^(bgB|b)lue/)
|| str.match(/^(bgC|c)yan/)
|| str.match(/^(bgG|g)rey/)
|| str.match(/^(bgW|w)hite/)
|| str.match(/^(bgB|b)lack/)
|| str.match(/^(bgG|g)reen/)
|| str.match(/^(bgY|y)ellow/)
|| str.match(/^(bgM|m)agenta/)
|| str.match(/^(bgB|b)right(Black|Red|Green|Yellow|Blue|Magenta|Cyan|White)/);
if (match) {
var method = match[0];
var suffix = str.substr(method.length);
var isBg = startWith(method, 'bg');
if (isBg) {
method = lcFirst(method.substr(2));
}
if (typeof cursor[method] === 'function') {
return {
isBg: isBg,
method: method,
suffix: suffix
};
}
}
}
function parseFont(cursor, str) {
var match = str.match(/^bold|italic|underline|inverse/);
if (match) {
var method = match[0];
var suffix = str.substr(method.length);
if (typeof cursor[method] === 'function') {
return {
isFont: true,
method: method,
suffix: suffix
};
}
}
}
function parseGradient(str) {
var match = str.match(/^\((.+),(.+)\)/);
if (match) {
var color1 = match[1].trim();
var color2 = match[2].trim();
color1 = startWith(color1, '#') ? hex2rgb(color1) : name2rgb(color1);
color2 = startWith(color2, '#') ? hex2rgb(color2) : name2rgb(color2);
if (color1 && color2) {
return {
method: 'gradient',
color1: color1,
color2: color2
};
}
}
}
function interpolate(color1, color2, percent) {
return {
r: atPercent(color1.r, color2.r, percent),
g: atPercent(color1.g, color2.g, percent),
b: atPercent(color1.b, color2.b, percent)
};
}
function atPercent(a, b, percent) {
return a + Math.round((b - a) * percent);
}
function hex2rgb(color) {
var c = color.substring(1);
var r = c.substring(0, 2);
var g = c.substring(2, 4);
var b = c.substring(4, 6);
return {
r: parseInt(r, 16),
g: parseInt(g, 16),
b: parseInt(b, 16)
};
}
function name2rgb(name) {
var hex = {
red: '#ff0000',
blue: '#0000ff',
cyan: '#00ffff',
grey: '#808080',
white: '#ffffff',
black: '#000000',
green: '#008000',
yellow: '#ffff00',
magenta: '#ff00ff'
}[name];
return hex ? hex2rgb(hex) : null;
}
function bleach(output) {
return output
.replace(/\.(bgR|r)ed/g, '')
.replace(/\.(bgB|b)lue/g, '')
.replace(/\.(bgC|c)yan/g, '')
.replace(/\.(bgG|g)rey/g, '')
.replace(/\.(bgW|w)hite/g, '')
.replace(/\.(bgB|b)lack/g, '')
.replace(/\.(bgG|g)reen/g, '')
.replace(/\.(bgY|y)ellow/g, '')
.replace(/\.(bgM|m)agenta/g, '')
// bright
.replace(/\.(bgB|b)right(Black|Red|Green|Yellow|Blue|Magenta|Cyan|White)/g, '')
// font style
.replace(/\.bold|italic|underline|inverse/g, '')
// gradient
.replace(/\.gradient\((.+),(.+)\)/g, '');
}
function combine(output, filled, blank, bare) {
var bar = filled + blank;
if (!bare) {
bar = bar || placeholder;
blank = blank || placeholder;
filled = filled || placeholder;
}
return output
.replace(/:filled/g, filled)
.replace(/:blank/g, blank)
.replace(/:bar/g, bar);
}
function bareLength(output) {
var str = output
.replace(/:filled/g, '')
.replace(/:blank/g, '')
.replace(/:bar/g, '');
return str.length;
}