Last active
June 24, 2025 21:53
-
-
Save denilsonsa/2dbb52caaf75e6b40674c330b3f27944 to your computer and use it in GitHub Desktop.
Stupid Simple Progress Bar (printing nice progress bars at the terminal)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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