progressimo 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +298 -0
- package/bin/demo.js +88 -0
- package/index.js +10 -0
- package/package.json +48 -0
- package/src/ProgressBar.js +229 -0
- package/src/palettes.js +37 -0
- package/src/renderer.js +60 -0
- package/src/themes.js +68 -0
- package/themes/custom-example.json +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
# progressimo
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+

|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
A lightweight, animated, themeable CLI progress bar for Node.js.
|
|
8
|
+
Zero config to start β full customization when needed.
|
|
9
|
+
|
|
10
|
+
- π¨ **6 built-in themes** β default, minimal, retro, blocks, dots, arrows
|
|
11
|
+
- βΏ **Colorblind-friendly palettes** β deuteranopia, protanopia, tritanopia
|
|
12
|
+
- π **Custom JSON themes** β load your own theme from a file
|
|
13
|
+
- β¨ **Smooth animation** β animated fill with configurable speed
|
|
14
|
+
- π¦ **Tiny footprint** β only 2 dependencies (`chalk`, `cli-cursor`)
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Quick Start
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install progressimo
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
```javascript
|
|
25
|
+
import ProgressBar from 'progressimo';
|
|
26
|
+
|
|
27
|
+
// Zero config β works out of the box
|
|
28
|
+
const bar = new ProgressBar({ total: 100 });
|
|
29
|
+
bar.update(50); // 50% done
|
|
30
|
+
bar.complete(); // jump to 100%, clean up
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
That's it β two lines to get a working progress bar.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Theme Previews
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
default [ββββββββββββββββββββββββββββββ] 60%
|
|
41
|
+
|
|
42
|
+
minimal [##################------------] 60%
|
|
43
|
+
|
|
44
|
+
retro [=================>------------] 60%
|
|
45
|
+
|
|
46
|
+
blocks [ββββββββββββββββββββββββββββββ] 60%
|
|
47
|
+
|
|
48
|
+
dots [β’β’β’β’β’β’β’β’β’β’β’β’β’β’β’β’β’β’Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·Β·] 60%
|
|
49
|
+
|
|
50
|
+
arrows [βΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΈβΉβΉβΉβΉβΉβΉβΉβΉβΉβΉβΉβΉ] 60%
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Usage Examples
|
|
56
|
+
|
|
57
|
+
### With a Built-in Theme
|
|
58
|
+
|
|
59
|
+
```javascript
|
|
60
|
+
import ProgressBar from 'progressimo';
|
|
61
|
+
|
|
62
|
+
const bar = new ProgressBar({ total: 100, theme: 'retro' });
|
|
63
|
+
bar.update(60);
|
|
64
|
+
// retro [===============>--------------] 60%
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### With a Colorblind Palette
|
|
68
|
+
|
|
69
|
+
```javascript
|
|
70
|
+
const bar = new ProgressBar({
|
|
71
|
+
total: 100,
|
|
72
|
+
palette: 'deuteranopia', // blue + orange β safe for red/green CVD
|
|
73
|
+
});
|
|
74
|
+
bar.update(75);
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Fully Custom Config
|
|
78
|
+
|
|
79
|
+
```javascript
|
|
80
|
+
const bar = new ProgressBar({
|
|
81
|
+
total: 100,
|
|
82
|
+
width: 40,
|
|
83
|
+
label: 'Uploading',
|
|
84
|
+
fill: 'β°',
|
|
85
|
+
empty: 'β±',
|
|
86
|
+
color: 'magentaBright',
|
|
87
|
+
});
|
|
88
|
+
bar.update(30);
|
|
89
|
+
// Uploading [β°β°β°β°β°β°β°β°β°β°β°β°β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±β±] 30%
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Load a Custom JSON Theme
|
|
93
|
+
|
|
94
|
+
Create a JSON file like `themes/my-theme.json`:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"fill": "β
",
|
|
99
|
+
"empty": "β",
|
|
100
|
+
"head": "",
|
|
101
|
+
"leftBracket": "[",
|
|
102
|
+
"rightBracket": "]",
|
|
103
|
+
"color": "yellow"
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Then use it:
|
|
108
|
+
|
|
109
|
+
```javascript
|
|
110
|
+
const bar = new ProgressBar({
|
|
111
|
+
total: 100,
|
|
112
|
+
theme: './themes/my-theme.json',
|
|
113
|
+
});
|
|
114
|
+
bar.update(50);
|
|
115
|
+
// [β
β
β
β
β
β
β
β
β
β
β
β
β
β
β
βββββββββββββββ] 50%
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Smooth Animation
|
|
119
|
+
|
|
120
|
+
```javascript
|
|
121
|
+
const bar = new ProgressBar({ total: 100, label: 'Installing' });
|
|
122
|
+
|
|
123
|
+
// The bar will smoothly slide from 0 β 80 instead of jumping
|
|
124
|
+
bar.update(80, { animate: true });
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### Using with Async Tasks
|
|
128
|
+
|
|
129
|
+
```javascript
|
|
130
|
+
import ProgressBar from 'progressimo';
|
|
131
|
+
|
|
132
|
+
async function downloadFiles(files) {
|
|
133
|
+
const bar = new ProgressBar({
|
|
134
|
+
total: files.length,
|
|
135
|
+
label: 'Downloading',
|
|
136
|
+
theme: 'dots',
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
for (let i = 0; i < files.length; i++) {
|
|
140
|
+
await downloadFile(files[i]);
|
|
141
|
+
bar.update(i + 1);
|
|
142
|
+
}
|
|
143
|
+
bar.complete();
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Increment Shortcut
|
|
148
|
+
|
|
149
|
+
```javascript
|
|
150
|
+
const bar = new ProgressBar({ total: 200 });
|
|
151
|
+
|
|
152
|
+
bar.increment(); // 1/200
|
|
153
|
+
bar.increment(10); // 11/200
|
|
154
|
+
bar.increment(); // 12/200
|
|
155
|
+
bar.complete(); // 200/200, done
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Config Options
|
|
161
|
+
|
|
162
|
+
| Option | Type | Default | Description |
|
|
163
|
+
|---|---|---|---|
|
|
164
|
+
| `total` | `number` | `100` | Value that represents 100% |
|
|
165
|
+
| `theme` | `string` | `'default'` | Built-in theme name or path to a `.json` theme file |
|
|
166
|
+
| `palette` | `string` | β | Colorblind palette: `'deuteranopia'`, `'protanopia'`, `'tritanopia'` |
|
|
167
|
+
| `width` | `number` | `30` | Character width of the bar (excluding brackets/label) |
|
|
168
|
+
| `label` | `string` | `''` | Text label before the bar |
|
|
169
|
+
| `fill` | `string` | `'β'` | Fill character (overrides theme) |
|
|
170
|
+
| `empty` | `string` | `'β'` | Empty character (overrides theme) |
|
|
171
|
+
| `head` | `string` | `''` | Leading edge character (overrides theme) |
|
|
172
|
+
| `color` | `string` | `'cyan'` | Chalk color name for the filled portion |
|
|
173
|
+
| `animationInterval` | `number` | `50` | Milliseconds between frames during smooth animation |
|
|
174
|
+
| `stream` | `WriteStream` | `process.stderr` | Output stream |
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Built-in Themes
|
|
179
|
+
|
|
180
|
+
| Theme | Fill | Empty | Head | Color |
|
|
181
|
+
|---|---|---|---|---|
|
|
182
|
+
| `default` | `β` | `β` | β | cyan |
|
|
183
|
+
| `minimal` | `#` | `-` | β | white |
|
|
184
|
+
| `retro` | `=` | `-` | `>` | green |
|
|
185
|
+
| `blocks` | `β` | `β` | β | magenta |
|
|
186
|
+
| `dots` | `β’` | `Β·` | β | yellow |
|
|
187
|
+
| `arrows` | `βΈ` | `βΉ` | β | blue |
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Colorblind Palettes
|
|
192
|
+
|
|
193
|
+
These palettes remap colors to combinations that are distinguishable for people with color vision deficiencies.
|
|
194
|
+
|
|
195
|
+
| Palette | Fill Color | Empty Color | Target |
|
|
196
|
+
|---|---|---|---|
|
|
197
|
+
| `deuteranopia` | blue | bright yellow | Reduced green sensitivity (most common) |
|
|
198
|
+
| `protanopia` | bright blue | yellow | Reduced red sensitivity |
|
|
199
|
+
| `tritanopia` | red | bright cyan | Reduced blue sensitivity |
|
|
200
|
+
|
|
201
|
+
---
|
|
202
|
+
|
|
203
|
+
## API Reference
|
|
204
|
+
|
|
205
|
+
### `new ProgressBar(options?)`
|
|
206
|
+
|
|
207
|
+
Create a new progress bar instance. All options are optional β zero config works out of the box.
|
|
208
|
+
|
|
209
|
+
### `.update(value, options?)`
|
|
210
|
+
|
|
211
|
+
Set progress to an absolute value (clamped to 0βtotal).
|
|
212
|
+
Pass `{ animate: true }` for smooth animation.
|
|
213
|
+
|
|
214
|
+
### `.increment(delta?)`
|
|
215
|
+
|
|
216
|
+
Add `delta` (default `1`) to the current progress.
|
|
217
|
+
|
|
218
|
+
### `.complete()`
|
|
219
|
+
|
|
220
|
+
Jump to 100%, render the final frame, restore the cursor, and print a newline.
|
|
221
|
+
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
## Advanced: Accessing Themes & Palettes
|
|
225
|
+
|
|
226
|
+
```javascript
|
|
227
|
+
import { themes, palettes } from 'progressimo';
|
|
228
|
+
|
|
229
|
+
console.log(Object.keys(themes));
|
|
230
|
+
// ['default', 'minimal', 'retro', 'blocks', 'dots', 'arrows']
|
|
231
|
+
|
|
232
|
+
console.log(palettes.deuteranopia);
|
|
233
|
+
// { fill: 'blue', empty: 'yellowBright', label: 'white', percentage: 'blueBright' }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Demo
|
|
239
|
+
|
|
240
|
+
Run the interactive demo that cycles through all themes and palettes:
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
npx progressimo-demo
|
|
244
|
+
# or
|
|
245
|
+
node node_modules/progressimo/bin/demo.js
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
---
|
|
249
|
+
|
|
250
|
+
## How It Works
|
|
251
|
+
|
|
252
|
+
The "animation" trick is simple: instead of printing new lines, we overwrite the same terminal line on every update.
|
|
253
|
+
|
|
254
|
+
1. `readline.cursorTo(stream, 0)` β moves the cursor back to column 0
|
|
255
|
+
2. `readline.clearLine(stream, 0)` β erases the current line
|
|
256
|
+
3. `stream.write(barString)` β writes the new bar
|
|
257
|
+
|
|
258
|
+
Because we never print a `\n`, the cursor stays on the same line. `cli-cursor` hides the blinking cursor during animation for a clean look.
|
|
259
|
+
|
|
260
|
+
Progress output goes to `stderr` by default so `stdout` stays clean for piping.
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
## Publishing to npm
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
# Login to your npm account
|
|
268
|
+
npm login
|
|
269
|
+
|
|
270
|
+
# Publish (first time)
|
|
271
|
+
npm publish
|
|
272
|
+
|
|
273
|
+
# After making changes, bump the version
|
|
274
|
+
npm version patch # 1.0.0 β 1.0.1
|
|
275
|
+
npm publish
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
**Key concepts:**
|
|
279
|
+
- `"main"` in package.json tells Node.js which file to load on `import`
|
|
280
|
+
- `"bin"` registers CLI commands (like `progressimo-demo`)
|
|
281
|
+
- `"type": "module"` enables ES Module syntax (`import`/`export`)
|
|
282
|
+
- `"files"` controls which files are included in the published tarball
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
286
|
+
## Contributing
|
|
287
|
+
|
|
288
|
+
1. Fork the repo
|
|
289
|
+
2. Create a feature branch (`git checkout -b feature/my-theme`)
|
|
290
|
+
3. Commit your changes (`git commit -m 'Add new theme'`)
|
|
291
|
+
4. Push to the branch (`git push origin feature/my-theme`)
|
|
292
|
+
5. Open a Pull Request
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## License
|
|
297
|
+
|
|
298
|
+
MIT
|
package/bin/demo.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// βββ progressimo Demo βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
4
|
+
// Run: node bin/demo.js (or: npm run demo)
|
|
5
|
+
//
|
|
6
|
+
// Cycles through every built-in theme and colorblind palette so you can see
|
|
7
|
+
// what each one looks like in your terminal.
|
|
8
|
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
9
|
+
|
|
10
|
+
import chalk from 'chalk';
|
|
11
|
+
import ProgressBar from '../src/ProgressBar.js';
|
|
12
|
+
import themes from '../src/themes.js';
|
|
13
|
+
import palettes from '../src/palettes.js';
|
|
14
|
+
|
|
15
|
+
/** Promise-based sleep helper. */
|
|
16
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Animate a single bar from 0 β 100 with a label.
|
|
20
|
+
* @param {object} opts β ProgressBar options.
|
|
21
|
+
* @param {string} heading β section heading printed above the bar.
|
|
22
|
+
*/
|
|
23
|
+
async function showcase(opts, heading) {
|
|
24
|
+
if (heading) {
|
|
25
|
+
process.stderr.write(`\n${chalk.bold.underline(heading)}\n`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const bar = new ProgressBar({ total: 100, width: 30, ...opts });
|
|
29
|
+
|
|
30
|
+
// Simulate work: tick from 0 to 100
|
|
31
|
+
for (let i = 0; i <= 100; i += 2) {
|
|
32
|
+
bar.update(i);
|
|
33
|
+
await sleep(25); // 25 ms Γ 50 ticks = ~1.25 s per bar
|
|
34
|
+
}
|
|
35
|
+
bar.complete();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// βββ Main ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
39
|
+
|
|
40
|
+
async function main() {
|
|
41
|
+
console.log(chalk.bold.cyan('\nβ¨ progressimo β Theme & Palette Demo\n'));
|
|
42
|
+
console.log(chalk.gray('Each bar animates from 0 β 100 %.\n'));
|
|
43
|
+
|
|
44
|
+
// 1. Built-in themes
|
|
45
|
+
console.log(chalk.bold('βββ Built-in Themes βββ'));
|
|
46
|
+
for (const name of Object.keys(themes)) {
|
|
47
|
+
await showcase(
|
|
48
|
+
{ theme: name, label: name.padEnd(10) },
|
|
49
|
+
null,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 2. Colorblind palettes (using default theme characters)
|
|
54
|
+
console.log(chalk.bold('\nβββ Colorblind Palettes βββ'));
|
|
55
|
+
for (const name of Object.keys(palettes)) {
|
|
56
|
+
await showcase(
|
|
57
|
+
{ palette: name, label: name.padEnd(14) },
|
|
58
|
+
null,
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// 3. Custom JSON theme
|
|
63
|
+
console.log(chalk.bold('\nβββ Custom JSON Theme βββ'));
|
|
64
|
+
await showcase(
|
|
65
|
+
{ theme: './themes/custom-example.json', label: 'custom-json'.padEnd(14) },
|
|
66
|
+
null,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
// 4. Fully custom config (no theme, all overrides)
|
|
70
|
+
console.log(chalk.bold('\nβββ Fully Custom Config βββ'));
|
|
71
|
+
await showcase(
|
|
72
|
+
{
|
|
73
|
+
fill: 'β°',
|
|
74
|
+
empty: 'β±',
|
|
75
|
+
color: 'magentaBright',
|
|
76
|
+
label: 'uploading'.padEnd(14),
|
|
77
|
+
width: 35,
|
|
78
|
+
},
|
|
79
|
+
null,
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
console.log(chalk.bold.cyan('\nβ
Demo complete!\n'));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch((err) => {
|
|
86
|
+
console.error(err);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
package/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// βββ progressimo β Library Entry Point ββββββββββββββββββββββββββββββββββββββββ
|
|
2
|
+
// Usage:
|
|
3
|
+
// import ProgressBar from 'progressimo';
|
|
4
|
+
// import { themes, palettes } from 'progressimo';
|
|
5
|
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
6
|
+
|
|
7
|
+
export { default as default } from './src/ProgressBar.js';
|
|
8
|
+
export { default as ProgressBar } from './src/ProgressBar.js';
|
|
9
|
+
export { default as themes } from './src/themes.js';
|
|
10
|
+
export { default as palettes } from './src/palettes.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "progressimo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight, animated, themeable CLI progress bar for Node.js β zero config to start, full customization when needed.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"bin": {
|
|
11
|
+
"progressimo-demo": "./bin/demo.js"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src/",
|
|
15
|
+
"themes/",
|
|
16
|
+
"bin/",
|
|
17
|
+
"index.js",
|
|
18
|
+
"README.md"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"demo": "node bin/demo.js",
|
|
22
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
23
|
+
},
|
|
24
|
+
"keywords": [
|
|
25
|
+
"progress",
|
|
26
|
+
"progress-bar",
|
|
27
|
+
"cli",
|
|
28
|
+
"terminal",
|
|
29
|
+
"animation",
|
|
30
|
+
"colorblind",
|
|
31
|
+
"accessibility",
|
|
32
|
+
"themes"
|
|
33
|
+
],
|
|
34
|
+
"author": "realsahilsaini",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/realsahilsaini/clibar.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/realsahilsaini/clibar#readme",
|
|
41
|
+
"bugs": {
|
|
42
|
+
"url": "https://github.com/realsahilsaini/clibar/issues"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"chalk": "^5.6.2",
|
|
46
|
+
"cli-cursor": "^5.0.0"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import Renderer from './renderer.js';
|
|
5
|
+
import themes from './themes.js';
|
|
6
|
+
import palettes from './palettes.js';
|
|
7
|
+
|
|
8
|
+
// βββ ProgressBar βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
9
|
+
// The main class consumers interact with.
|
|
10
|
+
//
|
|
11
|
+
// Quick start (zero config):
|
|
12
|
+
// const bar = new ProgressBar({ total: 100 });
|
|
13
|
+
// bar.update(50); // jump to 50 %
|
|
14
|
+
// bar.increment(); // 51 %
|
|
15
|
+
// bar.complete(); // 100 %, done
|
|
16
|
+
//
|
|
17
|
+
// With a theme:
|
|
18
|
+
// new ProgressBar({ total: 100, theme: 'retro' });
|
|
19
|
+
//
|
|
20
|
+
// With a colorblind palette:
|
|
21
|
+
// new ProgressBar({ total: 100, palette: 'deuteranopia' });
|
|
22
|
+
//
|
|
23
|
+
// Custom JSON theme:
|
|
24
|
+
// new ProgressBar({ total: 100, theme: './themes/my-theme.json' });
|
|
25
|
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
26
|
+
|
|
27
|
+
// Allowed keys in a theme object β used for validation when loading JSON.
|
|
28
|
+
const ALLOWED_THEME_KEYS = new Set([
|
|
29
|
+
'fill', 'empty', 'head', 'leftBracket', 'rightBracket', 'color',
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Load and validate a theme from a JSON file.
|
|
34
|
+
* @param {string} filePath β absolute or relative path to a .json theme file.
|
|
35
|
+
* @returns {object} β validated theme object.
|
|
36
|
+
*/
|
|
37
|
+
function loadThemeFromFile(filePath) {
|
|
38
|
+
const resolved = path.resolve(filePath);
|
|
39
|
+
const raw = fs.readFileSync(resolved, 'utf-8');
|
|
40
|
+
const parsed = JSON.parse(raw);
|
|
41
|
+
|
|
42
|
+
// Only allow expected string keys β prevents prototype pollution / injection.
|
|
43
|
+
const safe = {};
|
|
44
|
+
for (const key of ALLOWED_THEME_KEYS) {
|
|
45
|
+
if (key in parsed) {
|
|
46
|
+
if (typeof parsed[key] !== 'string') {
|
|
47
|
+
throw new TypeError(`Theme key "${key}" must be a string, got ${typeof parsed[key]}`);
|
|
48
|
+
}
|
|
49
|
+
safe[key] = parsed[key];
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return safe;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export default class ProgressBar {
|
|
56
|
+
/**
|
|
57
|
+
* Create a new progress bar.
|
|
58
|
+
*
|
|
59
|
+
* @param {object} [opts] β configuration (all optional).
|
|
60
|
+
* @param {number} [opts.total=100] β value that represents 100 %.
|
|
61
|
+
* @param {string} [opts.theme='default'] β built-in theme name OR path to a .json theme file.
|
|
62
|
+
* @param {string} [opts.palette] β colorblind palette name ('deuteranopia' | 'protanopia' | 'tritanopia').
|
|
63
|
+
* @param {number} [opts.width=30] β character width of the bar (excluding brackets/label).
|
|
64
|
+
* @param {string} [opts.label=''] β text label shown before the bar.
|
|
65
|
+
* @param {string} [opts.fill] β override fill character.
|
|
66
|
+
* @param {string} [opts.empty] β override empty character.
|
|
67
|
+
* @param {string} [opts.head] β override head character.
|
|
68
|
+
* @param {string} [opts.color] β override chalk color for the filled portion.
|
|
69
|
+
* @param {number} [opts.animationInterval=50] β ms between frames when animating.
|
|
70
|
+
* @param {NodeJS.WriteStream} [opts.stream] β output stream (defaults to stderr).
|
|
71
|
+
*/
|
|
72
|
+
constructor(opts = {}) {
|
|
73
|
+
// ββ Resolve theme ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
74
|
+
let baseTheme;
|
|
75
|
+
const themeName = opts.theme ?? 'default';
|
|
76
|
+
|
|
77
|
+
if (typeof themeName === 'string' && themeName.endsWith('.json')) {
|
|
78
|
+
// External JSON theme file
|
|
79
|
+
baseTheme = loadThemeFromFile(themeName);
|
|
80
|
+
} else if (themes[themeName]) {
|
|
81
|
+
baseTheme = { ...themes[themeName] };
|
|
82
|
+
} else {
|
|
83
|
+
baseTheme = { ...themes.default };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ββ Merge: base theme β user overrides βββββββββββββββββββββββββββββββββ
|
|
87
|
+
this.fill = opts.fill ?? baseTheme.fill ?? 'β';
|
|
88
|
+
this.empty = opts.empty ?? baseTheme.empty ?? 'β';
|
|
89
|
+
this.head = opts.head ?? baseTheme.head ?? '';
|
|
90
|
+
this.leftBracket = opts.leftBracket ?? baseTheme.leftBracket ?? '[';
|
|
91
|
+
this.rightBracket = opts.rightBracket ?? baseTheme.rightBracket ?? ']';
|
|
92
|
+
this.color = opts.color ?? baseTheme.color ?? 'cyan';
|
|
93
|
+
|
|
94
|
+
// ββ Resolve palette (overrides colors when set) ββββββββββββββββββββββββ
|
|
95
|
+
this.palette = null;
|
|
96
|
+
if (opts.palette && palettes[opts.palette]) {
|
|
97
|
+
this.palette = palettes[opts.palette];
|
|
98
|
+
// Palette's fill color wins over theme color if not explicitly set by user.
|
|
99
|
+
if (!opts.color) {
|
|
100
|
+
this.color = this.palette.fill;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ββ Other settings βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
105
|
+
this.total = opts.total ?? 100;
|
|
106
|
+
this.width = opts.width ?? 30;
|
|
107
|
+
this.label = opts.label ?? '';
|
|
108
|
+
this.animationInterval = opts.animationInterval ?? 50;
|
|
109
|
+
|
|
110
|
+
// ββ Internal state βββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
111
|
+
this.current = 0; // actual logical value
|
|
112
|
+
this.displayValue = 0; // smoothly-animated visual value
|
|
113
|
+
this._timer = null; // setInterval id for animation
|
|
114
|
+
|
|
115
|
+
// ββ Renderer βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
116
|
+
this.renderer = new Renderer(opts.stream);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// βββ Public API ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Set the progress to an absolute value and redraw.
|
|
123
|
+
* @param {number} value β progress value (clamped to 0β¦total).
|
|
124
|
+
* @param {object} [opts]
|
|
125
|
+
* @param {boolean} [opts.animate=false] β smoothly animate to the new value.
|
|
126
|
+
*/
|
|
127
|
+
update(value, { animate = false } = {}) {
|
|
128
|
+
this.current = Math.max(0, Math.min(value, this.total));
|
|
129
|
+
|
|
130
|
+
if (animate) {
|
|
131
|
+
this._animateTo(this.current);
|
|
132
|
+
} else {
|
|
133
|
+
this.displayValue = this.current;
|
|
134
|
+
this._draw();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Increment progress by `delta` and redraw.
|
|
140
|
+
* @param {number} [delta=1] β amount to add.
|
|
141
|
+
*/
|
|
142
|
+
increment(delta = 1) {
|
|
143
|
+
this.update(this.current + delta);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Jump to 100 %, render the final frame, and clean up.
|
|
148
|
+
*/
|
|
149
|
+
complete() {
|
|
150
|
+
this._clearTimer();
|
|
151
|
+
this.current = this.total;
|
|
152
|
+
this.displayValue = this.total;
|
|
153
|
+
this._draw();
|
|
154
|
+
this.renderer.finish();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// βββ Internal ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Smoothly animate `displayValue` toward `target`.
|
|
161
|
+
*
|
|
162
|
+
* HOW IT WORKS:
|
|
163
|
+
* setInterval fires a callback on a repeating timer. Each tick we nudge
|
|
164
|
+
* displayValue closer to the target by a small step. On each tick _draw()
|
|
165
|
+
* repaints the bar β so the user sees the fill sliding smoothly instead
|
|
166
|
+
* of jumping. When displayValue reaches the target, we clear the interval.
|
|
167
|
+
*
|
|
168
|
+
* @param {number} target β the value to animate toward.
|
|
169
|
+
* @private
|
|
170
|
+
*/
|
|
171
|
+
_animateTo(target) {
|
|
172
|
+
this._clearTimer();
|
|
173
|
+
|
|
174
|
+
const step = Math.max(1, Math.round(this.total / this.width));
|
|
175
|
+
|
|
176
|
+
this._timer = setInterval(() => {
|
|
177
|
+
if (this.displayValue < target) {
|
|
178
|
+
this.displayValue = Math.min(this.displayValue + step, target);
|
|
179
|
+
} else if (this.displayValue > target) {
|
|
180
|
+
this.displayValue = Math.max(this.displayValue - step, target);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
this._draw();
|
|
184
|
+
|
|
185
|
+
if (this.displayValue === target) {
|
|
186
|
+
this._clearTimer();
|
|
187
|
+
}
|
|
188
|
+
}, this.animationInterval);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Compute the bar string and hand it to the renderer.
|
|
193
|
+
* @private
|
|
194
|
+
*/
|
|
195
|
+
_draw() {
|
|
196
|
+
const ratio = Math.min(this.displayValue / this.total, 1);
|
|
197
|
+
const filled = Math.round(ratio * this.width);
|
|
198
|
+
const empty = this.width - filled;
|
|
199
|
+
const pct = Math.round(ratio * 100);
|
|
200
|
+
|
|
201
|
+
// Build character segments
|
|
202
|
+
let fillStr = this.fill.repeat(Math.max(0, filled - (this.head ? 1 : 0)));
|
|
203
|
+
let headStr = (filled > 0 && this.head) ? this.head : '';
|
|
204
|
+
let emptyStr = this.empty.repeat(empty);
|
|
205
|
+
|
|
206
|
+
// Apply colors via chalk
|
|
207
|
+
const colorFn = chalk[this.color] || chalk.cyan;
|
|
208
|
+
const emptyColor = this.palette ? (chalk[this.palette.empty] || chalk.gray) : chalk.gray;
|
|
209
|
+
const labelColor = this.palette ? (chalk[this.palette.label] || chalk.white) : chalk.white;
|
|
210
|
+
const pctColor = this.palette ? (chalk[this.palette.percentage] || chalk.white) : chalk.white;
|
|
211
|
+
|
|
212
|
+
const coloredFill = colorFn(fillStr + headStr);
|
|
213
|
+
const coloredEmpty = emptyColor(emptyStr);
|
|
214
|
+
const coloredLabel = this.label ? labelColor(this.label) + ' ' : '';
|
|
215
|
+
const coloredPct = pctColor(`${pct}%`);
|
|
216
|
+
|
|
217
|
+
const bar = `${coloredLabel}${this.leftBracket}${coloredFill}${coloredEmpty}${this.rightBracket} ${coloredPct}`;
|
|
218
|
+
|
|
219
|
+
this.renderer.render(bar);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Clear the animation interval if running. @private */
|
|
223
|
+
_clearTimer() {
|
|
224
|
+
if (this._timer !== null) {
|
|
225
|
+
clearInterval(this._timer);
|
|
226
|
+
this._timer = null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
package/src/palettes.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// βββ Colorblind-Friendly Palette Presets βββββββββββββββββββββββββββββββββββββ
|
|
2
|
+
// Each palette maps semantic roles to chalk color names that are safe
|
|
3
|
+
// for a specific type of color vision deficiency.
|
|
4
|
+
//
|
|
5
|
+
// fill β color applied to the filled/completed portion
|
|
6
|
+
// empty β color applied to the remaining/empty portion
|
|
7
|
+
// label β color applied to the label text
|
|
8
|
+
// percentage β color applied to the percentage number
|
|
9
|
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
10
|
+
|
|
11
|
+
const palettes = {
|
|
12
|
+
/** Deuteranopia β reduced green sensitivity (most common CVD) */
|
|
13
|
+
deuteranopia: {
|
|
14
|
+
fill: 'blue',
|
|
15
|
+
empty: 'yellowBright',
|
|
16
|
+
label: 'white',
|
|
17
|
+
percentage: 'blueBright',
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
/** Protanopia β reduced red sensitivity */
|
|
21
|
+
protanopia: {
|
|
22
|
+
fill: 'blueBright',
|
|
23
|
+
empty: 'yellow',
|
|
24
|
+
label: 'white',
|
|
25
|
+
percentage: 'blue',
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/** Tritanopia β reduced blue sensitivity (rarest form) */
|
|
29
|
+
tritanopia: {
|
|
30
|
+
fill: 'red',
|
|
31
|
+
empty: 'cyanBright',
|
|
32
|
+
label: 'white',
|
|
33
|
+
percentage: 'redBright',
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export default palettes;
|
package/src/renderer.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import cliCursor from 'cli-cursor';
|
|
3
|
+
|
|
4
|
+
// βββ Renderer ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
5
|
+
// Handles all direct terminal I/O β clearing, redrawing and cursor visibility.
|
|
6
|
+
//
|
|
7
|
+
// HOW IT WORKS (key concept):
|
|
8
|
+
// Terminals print text left-to-right, line-by-line. Normally each write
|
|
9
|
+
// appends new content. To "animate" a progress bar we need to *overwrite*
|
|
10
|
+
// the same line repeatedly. We do that in two steps every frame:
|
|
11
|
+
//
|
|
12
|
+
// 1. readline.cursorTo(stream, 0) β moves the cursor back to column 0
|
|
13
|
+
// 2. readline.clearLine(stream, 0) β erases everything on the current line
|
|
14
|
+
//
|
|
15
|
+
// Then we write the new bar string. Because we never print a newline (\n)
|
|
16
|
+
// the cursor stays on the same line, ready for the next overwrite.
|
|
17
|
+
//
|
|
18
|
+
// cli-cursor hides the blinking cursor during animation so it doesn't
|
|
19
|
+
// flicker over the bar characters β purely cosmetic but looks much cleaner.
|
|
20
|
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
21
|
+
|
|
22
|
+
export default class Renderer {
|
|
23
|
+
/**
|
|
24
|
+
* @param {NodeJS.WriteStream} [stream=process.stderr] β output stream.
|
|
25
|
+
* stderr is the convention for progress indicators so stdout stays
|
|
26
|
+
* clean for actual program output (important when piping).
|
|
27
|
+
*/
|
|
28
|
+
constructor(stream = process.stderr) {
|
|
29
|
+
this.stream = stream;
|
|
30
|
+
this.started = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Hide the cursor and mark the renderer as active. */
|
|
34
|
+
start() {
|
|
35
|
+
if (this.started) return;
|
|
36
|
+
cliCursor.hide(this.stream);
|
|
37
|
+
this.started = true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Overwrite the current terminal line with `text`.
|
|
42
|
+
* @param {string} text β the fully-assembled bar string to display.
|
|
43
|
+
*/
|
|
44
|
+
render(text) {
|
|
45
|
+
if (!this.started) this.start();
|
|
46
|
+
|
|
47
|
+
// Move cursor to column 0, clear the line, then write the new text.
|
|
48
|
+
readline.cursorTo(this.stream, 0);
|
|
49
|
+
readline.clearLine(this.stream, 0);
|
|
50
|
+
this.stream.write(text);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Print a final newline, restore the cursor, and mark done. */
|
|
54
|
+
finish() {
|
|
55
|
+
if (!this.started) return;
|
|
56
|
+
this.stream.write('\n');
|
|
57
|
+
cliCursor.show(this.stream);
|
|
58
|
+
this.started = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/src/themes.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// βββ Built-in Theme Presets βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
2
|
+
// Each theme defines the characters and color used to render the progress bar.
|
|
3
|
+
//
|
|
4
|
+
// fill β character for the completed portion
|
|
5
|
+
// empty β character for the remaining portion
|
|
6
|
+
// head β character at the leading edge of the fill (optional)
|
|
7
|
+
// leftBracket β left wrapper around the bar
|
|
8
|
+
// rightBracket β right wrapper around the bar
|
|
9
|
+
// color β chalk color name applied to the filled portion
|
|
10
|
+
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
11
|
+
|
|
12
|
+
const themes = {
|
|
13
|
+
default: {
|
|
14
|
+
fill: 'β',
|
|
15
|
+
empty: 'β',
|
|
16
|
+
head: '',
|
|
17
|
+
leftBracket: '[',
|
|
18
|
+
rightBracket: ']',
|
|
19
|
+
color: 'cyan',
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
minimal: {
|
|
23
|
+
fill: '#',
|
|
24
|
+
empty: '-',
|
|
25
|
+
head: '',
|
|
26
|
+
leftBracket: '[',
|
|
27
|
+
rightBracket: ']',
|
|
28
|
+
color: 'white',
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
retro: {
|
|
32
|
+
fill: '=',
|
|
33
|
+
empty: '-',
|
|
34
|
+
head: '>',
|
|
35
|
+
leftBracket: '[',
|
|
36
|
+
rightBracket: ']',
|
|
37
|
+
color: 'green',
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
blocks: {
|
|
41
|
+
fill: 'β',
|
|
42
|
+
empty: 'β',
|
|
43
|
+
head: '',
|
|
44
|
+
leftBracket: '[',
|
|
45
|
+
rightBracket: ']',
|
|
46
|
+
color: 'magenta',
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
dots: {
|
|
50
|
+
fill: 'β’',
|
|
51
|
+
empty: 'Β·',
|
|
52
|
+
head: '',
|
|
53
|
+
leftBracket: '[',
|
|
54
|
+
rightBracket: ']',
|
|
55
|
+
color: 'yellow',
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
arrows: {
|
|
59
|
+
fill: 'βΈ',
|
|
60
|
+
empty: 'βΉ',
|
|
61
|
+
head: '',
|
|
62
|
+
leftBracket: '[',
|
|
63
|
+
rightBracket: ']',
|
|
64
|
+
color: 'blue',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export default themes;
|