Skip to content

Instantly share code, notes, and snippets.

@denilsonsa
Last active June 24, 2025 21:53
Show Gist options
  • Save denilsonsa/2dbb52caaf75e6b40674c330b3f27944 to your computer and use it in GitHub Desktop.
Save denilsonsa/2dbb52caaf75e6b40674c330b3f27944 to your computer and use it in GitHub Desktop.
Stupid Simple Progress Bar (printing nice progress bars at the terminal)
#!/usr/bin/env node
// Stupid simple progress bar.
class StupidSimpleProgressBar {
constructor(blocks, {prefix='', suffix='', totalWidth=78}) {
this.blocks = blocks;
this.prefixes = prefix.split('\n');
this.suffixes = suffix.split('\n');
this.totalWidth = totalWidth;
}
xfixWidth(arr) {
return Math.max(arr.map(s => s.length));
}
get prefixWidth() {
return this.xfixWidth(this.prefixes);
}
get suffixWidth() {
return this.xfixWidth(this.suffixes);
}
xfixLine(arr, index) {
const w = this.xfixWidth(arr);
if (w > 0) {
const s = arr[index] ?? '';
const padLeft = (w - s.length) / 2 + 1;
const padRight = (w - s.length + 0.5) / 2 + 1;
return ' '.repeat(padLeft) + s + ' '.repeat(padRight);
}
return '';
}
prefixLine(index) {
return this.xfixLine(this.prefixes, index);
}
suffixLine(index) {
return this.xfixLine(this.suffixes, index);
}
static DEFAULT_CHARS = [
'=',
'-',
'.',
':',
];
static UNICODE_BARS = [
' ',
'▏',
'▎',
'▍',
'▌',
'▋',
'▊',
'▉',
'█',
];
static BG_COLORS = {
'black': 40,
'red': 41,
'green': 42,
'yellow': 43,
'blue': 44,
'magenta': 45,
'cyan': 46,
'white': 47,
};
static getColor(nameOrCode, isForeground) {
if ((nameOrCode ?? '') === '') {
// null, undefined, empty string
return '';
}
let color = this.BG_COLORS[nameOrCode];
if (color) {
// Basic 8 colors
// (16 if we count the bright bit, but we don't use that here)
return isForeground ? color - 10 : color;
}
if (/\d+;\d+;\d+/.test(nameOrCode)) {
// Let's assume this is RGB in the format '255;255;255'
return (isForeground ? '38;2;' : '48;2;') + nameOrCode;
}
if (nameOrCode >= 0 && nameOrCode <= 255) {
// One of the 256 colors.
// Colors 0..15 are the original 16 colors.
// Colors 16..231 are the 6-values-per-component RGB.
// Colors 232..255 are grayscale.
return (isForeground ? '38;5;' : '48;5;') + nameOrCode;
}
throw new Error(`Invalid or unsupported color value: ${nameOrCode}`);
}
static termColor(foreground, background) {
const bg = this.getColor(background, false);
const fg = this.getColor(foreground, true);
const sep = fg && bg ? ';' : '';
return `\x1B[${fg}${sep}${bg}m`;
}
calculateBlocks(scaleToThisValue) {
let cummulativeValue = 0;
let i = 0;
const cummulative = [];
for (const item of this.blocks) {
cummulativeValue += item.value;
cummulative.push({
char: this.constructor.DEFAULT_CHARS[i % this.constructor.DEFAULT_CHARS.length],
...item,
index: i,
cummulative: cummulativeValue,
});
i++;
}
const total = cummulativeValue;
let cummulativeError = 0;
for (const item of cummulative) {
const idealValue = item.value / total * scaleToThisValue + cummulativeError;
item.scaledValue = Math.round(idealValue);
cummulativeError = idealValue - item.scaledValue;
}
return cummulative;
}
renderASCII() {
const prefix = this.prefixLine(0);
const suffix = this.suffixLine(0);
const availableWidth = this.totalWidth - prefix.length - suffix.length;
let blocks = this.calculateBlocks(availableWidth);
return [
prefix,
...blocks.map(item => this.constructor.termColor(item.color) + (item.char[0]).repeat(item.scaledValue)),
this.constructor.termColor(),
suffix,
].join('');
}
renderUnicode() {
const prefix = this.prefixLine(0);
const suffix = this.suffixLine(0);
let availableWidth = 8 * (this.totalWidth - prefix.length - suffix.length);
let blocks = this.calculateBlocks(availableWidth);
let ret = [];
ret.push(prefix);
let currentBlock = null;
while (blocks.length > 0 || currentBlock) {
if (!currentBlock) {
currentBlock = blocks.shift();
}
if (currentBlock.scaledValue > 8) {
const remainder = currentBlock.scaledValue % 8;
const fullChars = (currentBlock.scaledValue - remainder) / 8;
ret.push(this.constructor.termColor());
ret.push(this.constructor.termColor(currentBlock.color));
ret.push(this.constructor.UNICODE_BARS[8].repeat(fullChars));
currentBlock.scaledValue = remainder;
}
if (currentBlock.scaledValue == 0) {
currentBlock = null;
} else {
let mix = [currentBlock];
let mixTotal = currentBlock.scaledValue;
currentBlock = null;
while (mixTotal < 8) {
currentBlock = blocks.shift();
if (!currentBlock) { break; }
if (currentBlock.scaledValue == 0) {
continue;
}
if (currentBlock.scaledValue + mixTotal <= 8) {
mixTotal += currentBlock.scaledValue;
mix.push(currentBlock);
currentBlock = null;
} else {
const newBlock = {
...currentBlock,
scaledValue: 8 - mixTotal,
};
mixTotal += newBlock.scaledValue;
mix.push(newBlock);
currentBlock.scaledValue -= newBlock.scaledValue;
}
}
if (mix.length == 2) {
// Easy case.
const [first, second] = mix;
ret.push(this.constructor.termColor(first.color, second.color));
ret.push(this.constructor.UNICODE_BARS[first.scaledValue]);
} else {
// We can only draw two colors.
// It's impossible to properly draw it.
// I'd have to drop some of the items and proportionally distribute.
// Or I can cheat and just draw a fuzzy thing.
ret.push(this.constructor.termColor(mix[0].color, mix.at(-1).color));
ret.push('▒');
}
}
}
ret.push(this.constructor.termColor());
ret.push(suffix);
return ret.join('');
}
}
console.log(
new StupidSimpleProgressBar([
{ value: 5, char: '=', color: 'red' },
{ value: 15, char: '-', color: 'cyan' },
], {}).renderASCII()
);
console.log(
new StupidSimpleProgressBar([
{ value: 3, color: 202 },
{ value: 3, color: 40 },
{ value: 3, color: '255;0;255' },
], {
totalWidth: 10,
}).renderASCII()
);
console.log(
new StupidSimpleProgressBar([
{ value: 3, },
{ value: 3, },
{ value: 3, },
], {
totalWidth: 50,
}).renderASCII()
);
console.log(
new StupidSimpleProgressBar([
{ value: 3, },
{ value: 3, },
{ value: 3, },
], {
prefix: 'Hi',
suffix: 'Bye',
totalWidth: 50,
}).renderASCII()
);
console.log(
new StupidSimpleProgressBar([
{ value: 3, color: 202 },
{ value: 3, color: 40 },
{ value: 3, color: '255;0;255' },
], {
totalWidth: 10,
}).renderUnicode()
);
console.log(
new StupidSimpleProgressBar([
{ value: 8, color: 202 },
{ value: 16, color: 40 },
{ value: 16, color: '255;0;255' },
], {
totalWidth: 40,
}).renderUnicode()
);
console.log(
new StupidSimpleProgressBar([
{ value: 0, color: '255;0;255' },
{ value: 0, color: '255;0;255' },
{ value: 22, color: 202 },
{ value: 0, color: '255;0;255' },
{ value: 0, color: '255;0;255' },
{ value: 1, color: 40 },
{ value: 0, color: '255;0;255' },
{ value: 0, color: '255;0;255' },
{ value: 17, color: '127;64;192' },
{ value: 0, color: '255;0;255' },
{ value: 0, color: '255;0;255' },
], {
totalWidth: 10,
}).renderUnicode()
);
for (let i = 0; i <= 20; i++) {
console.log(
new StupidSimpleProgressBar([
{ value: i, color: 'green' },
{ value: 20 - i, color: 'blue' },
], {
prefix: `${i.toString().padStart(2)}/20`,
totalWidth: 10,
}).renderUnicode()
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment