UNPKG

20.1 kBJavaScriptView Raw
1import { roundD2, round, constraint } from '@aryth/math';
2import { STR } from '@typen/enum-data-types';
3import { valid } from '@typen/nullish';
4import { cursor, clear, decset } from '@arpel/escape';
5import { setInterval } from 'timers/promises';
6import { IO as IO$1 } from '@arpel/backend';
7import { LF } from '@pres/enum-control-chars';
8import * as rl from 'readline';
9
10const trailZero = num => {
11 if (!num) return '0';
12 const tx = '' + roundD2(num);
13 let i = tx.indexOf('.');
14
15 if (!~i) {
16 return tx + '.00';
17 }
18
19 const trail = tx.length - i;
20
21 if (trail === 3) {
22 return tx;
23 }
24
25 if (trail === 2) {
26 return tx + '0';
27 }
28
29 if (trail === 1) {
30 return tx + '00';
31 }
32
33 return tx;
34};
35
36const pad3 = tx => {
37 const len = tx.length;
38
39 if (len === 3) {
40 return tx;
41 }
42
43 if (len === 2) {
44 return ' ' + tx;
45 }
46
47 if (len === 1) {
48 return ' ' + tx;
49 }
50
51 if (len === 0) {
52 return ' ';
53 }
54
55 return tx;
56};
57
58const base3ToScale = (base3, dec) => {
59 if (base3 === 0) return 'B'; //
60
61 if (base3 === 1) return dec ? 'K' : 'KB'; // Kilo
62
63 if (base3 === 2) return dec ? 'M' : 'MB'; // Mega
64
65 if (base3 === 3) return dec ? 'G' : 'GB'; // Giga
66
67 if (base3 === 4) return dec ? 'T' : 'TB'; // Tera
68
69 if (base3 === 5) return dec ? 'P' : 'PB'; // Peta
70
71 if (base3 === 6) return dec ? 'E' : 'EB'; // Exa
72
73 if (base3 === 7) return dec ? 'Z' : 'ZB'; // Zetta
74
75 if (base3 === 8) return dec ? 'Y' : 'YB'; // Yotta
76};
77
78const DEFAULT_SENTENCE = 'progress [{bar}] {progress}% | ETA: {eta}s | {value}/{total}';
79
80class Layout {
81 /**
82 * @param {object} [options]
83 * @param {number} [options.size] size of the progressbar in chars
84 * @param {string[]|string} [options.char]
85 * @param {string} [options.glue]
86 * @param {string} [options.sentence]
87 * @param {boolean} [options.autoZero = false] autoZero - false
88 * @param {function(State):string} [options.bar]
89 * @param {function(State):string} [options.degree]
90 * @param {function(State,object):string} [options.format]
91 */
92 constructor(options) {
93 const char = typeof options.char === STR ? [options.char, ' '] : Array.isArray(options.char) ? options.char : ['=', '-'];
94 const [x, y] = char;
95 this.size = options.size ?? 24;
96 this.chars = [x.repeat(this.size + 1), y.repeat(this.size + 1)];
97 this.glue = options.glue ?? '';
98 this.autoZero = options.autoZero ?? false; // autoZero - false
99
100 this.sentence = options.sentence ?? DEFAULT_SENTENCE;
101 if (options.bar) this.bar = options.bar.bind(this);
102 if (options.degree) this.degree = options.degree.bind(this);
103 if (options.format) this.format = options.format.bind(this);
104 }
105
106 static build(options) {
107 return new Layout(options);
108 }
109
110 bar(state) {
111 const {
112 progress
113 } = state;
114 const {
115 chars,
116 glue,
117 size
118 } = this;
119 const lenX = round(progress * size),
120 lenY = size - lenX;
121 const [x, y] = chars; // generate bar string by stripping the pre-rendered strings
122
123 return x.slice(0, lenX) + glue + y.slice(0, lenY);
124 }
125
126 get fullBar() {
127 return this.chars[0].slice(0, this.size);
128 }
129
130 get zeroBar() {
131 return this.chars[1].slice(0, this.size);
132 }
133
134 degree(state) {
135 let {
136 value,
137 total
138 } = state;
139 const {
140 base3 = true,
141 decimal = false
142 } = this;
143 if (!base3) return `${round(value)}/${total}`;
144 const thousand = decimal ? 1000 : 1024;
145 let base3Level = 0;
146
147 while (total > thousand) {
148 // base3Level <= base3
149 total /= thousand;
150 value /= thousand;
151 base3Level++;
152 }
153
154 const t = trailZero(total);
155 const v = trailZero(value).padStart(t.length); // return { value: valueText, total: totalText, scale: base3ToScale(base3, dec) }
156
157 return `${v}/${t} ${base3ToScale(base3Level, decimal)}`;
158 }
159 /**
160 *
161 * @param {State|object} state
162 * @returns {string}
163 */
164
165
166 format(state) {
167 var _this$sentence;
168
169 return (_this$sentence = this.sentence) === null || _this$sentence === void 0 ? void 0 : _this$sentence.replace(/\{(\w+)\}/g, (match, key) => {
170 if (key === 'bar') return this.bar(state);
171 if (key === 'degree') return this.degree(state);
172 if (key in state) return state[key];
173 return match;
174 });
175 }
176
177}
178
179class Config {
180 /**
181 *
182 * @param {object} config
183 *
184 * @param {ReadStream} [config.input = process.stdin] the output stream to read from
185 * @param {WriteStream} [config.output = process.stdout] the output stream to write on
186 * @param {number} [config.fps = 12] the max update rate in fps (redraw will only triggered on value change)
187 * @param {IO|any} [config.terminal = null] external terminal provided ?
188 * @param {boolean} [config.autoClear = false] clear on finish ?
189 * @param {boolean} [config.autoStop = false] stop on finish ?
190 * @param {boolean} [config.hideCursor = false] hide the cursor ?
191 * @param {boolean} [config.lineWrap = false] allow or disable setLineWrap ?
192 * @param {string} [config.sentence = DEFAULT_FORMAT] the bar sentence
193 * @param {function} [config.formatTime = null] external time-sentence provided ?
194 * @param {function} [config.formatValue = null] external value-sentence provided ?
195 * @param {function} [config.formatBar = null] external bar-sentence provided ?
196 * @param {boolean} [config.syncUpdate = true] allow synchronous updates ?
197 * @param {boolean} [config.noTTYOutput = false] noTTY mode
198 * @param {number} [config.notTTYSchedule = 2000] schedule - 2s
199 * @param {boolean} [config.forceRedraw = false] force bar redraw even if progress did not change
200 *
201 * @param {object} [config.eta] eta config
202 * @param {boolean} [config.eta.on = false] switch to turn on eta
203 * @param {number} [config.eta.capacity = 10] the number of results to average ETA over
204 * @param {boolean} [config.eta.autoUpdate = false] automatic eta updates based on fps
205 * @returns {Config}
206 */
207 constructor(config) {
208 var _config$eta, _config$eta2;
209
210 // merge layout
211 // the max update rate in fps (redraw will only triggered on value change)
212 this.throttle = 1000 / (config.fps ?? 10); // the output stream to write on
213
214 this.output = config.output ?? process.stdout;
215 this.input = config.input ?? process.stdin;
216 this.eta = config.eta ? {
217 capacity: ((_config$eta = config.eta) === null || _config$eta === void 0 ? void 0 : _config$eta.capacity) ?? 10,
218 // the number of results to average ETA over
219 autoUpdate: ((_config$eta2 = config.eta) === null || _config$eta2 === void 0 ? void 0 : _config$eta2.autoUpdate) ?? false // automatic eta updates based on fps
220
221 } : null;
222 this.terminal = config.terminal ?? null; // external terminal provided ?
223
224 this.autoClear = config.autoClear ?? false; // clear on finish ?
225
226 this.autoStop = config.autoStop ?? false; // stop on finish ?
227
228 this.hideCursor = config.hideCursor ?? true; // hide the cursor ?
229
230 this.lineWrap = config.lineWrap ?? false; // disable setLineWrap ?
231
232 this.syncUpdate = config.syncUpdate ?? true; // allow synchronous updates ?
233
234 this.noTTYOutput = config.noTTYOutput ?? false; // noTTY mode
235
236 this.notTTYSchedule = config.notTTYSchedule ?? 2000; // schedule - 2s
237
238 this.forceRedraw = config.forceRedraw ?? false; // force bar redraw even if progress did not change
239
240 return this;
241 }
242
243 static build(config) {
244 return new Config(config);
245 }
246
247} // ETA: estimated time to completion
248
249
250class ETA {
251 constructor(capacity, initTime, initValue) {
252 // size of eta buffer
253 this.capacity = capacity || 100; // eta buffer with initial values
254
255 this.valueSeries = [initValue];
256 this.timeSeries = [initTime]; // eta time value
257
258 this.estimate = '0';
259 } // add new values to calculation buffer
260
261
262 update({
263 now,
264 value,
265 total
266 }) {
267 this.valueSeries.push(value);
268 this.timeSeries.push(now ?? Date.now());
269 this.calculate(total - value);
270 } // eta calculation - request number of remaining events
271
272
273 calculate(remaining) {
274 const len = this.valueSeries.length,
275 // get number of samples in eta buffer
276 cap = this.capacity,
277 hi = len - 1,
278 lo = len > cap ? len - cap : 0; // consumed-Math.min(cap,len)
279
280 const dValue = this.valueSeries[hi] - this.valueSeries[lo],
281 dTime = this.timeSeries[hi] - this.timeSeries[lo],
282 rate = dValue / dTime; // get progress per ms
283
284 if (len > cap) {
285 this.valueSeries = this.valueSeries.slice(-cap);
286 this.timeSeries = this.timeSeries.slice(-cap);
287 } // strip past elements
288
289
290 const eta = Math.ceil(remaining / (rate * 1000));
291 return this.estimate = isNaN(eta) ? 'NULL' : !isFinite(eta) ? 'INF' // +/- Infinity: NaN already handled
292 : eta > 1e7 ? 'INF' // > 10M s: - set upper display limit ~115days (1e7/60/60/24)
293 : eta < 0 ? 0 // negative: 0
294 : eta; // assign
295 }
296
297}
298
299class State {
300 constructor(data) {
301 this.value = data.value ?? 0;
302 this.total = data.total ?? 100;
303 this.start = data.start ?? Date.now(); // store start time for duration+eta calculation
304
305 this.end = data.end ?? null; // reset stop time for 're-start' scenario (used for duration calculation)
306
307 this.calETA = data.eta ? new ETA(data.eta.capacity ?? 64, this.start, this.value) : null; // initialize eta buffer
308
309 return this;
310 }
311
312 static build(data) {
313 return new State(data);
314 }
315
316 get eta() {
317 var _this$calETA;
318
319 return (_this$calETA = this.calETA) === null || _this$calETA === void 0 ? void 0 : _this$calETA.estimate;
320 }
321
322 get reachLimit() {
323 return this.value >= this.total;
324 }
325
326 get elapsed() {
327 return round(((this.end ?? Date.now()) - this.start) / 1000);
328 }
329
330 get percent() {
331 return pad3('' + ~~(this.progress * 100));
332 }
333
334 get progress() {
335 const progress = this.value / this.total;
336 return isNaN(progress) ? 0 // this.preset?.autoZero ? 0.0 : 1.0
337 : constraint(progress, 0, 1);
338 }
339
340 update(value) {
341 var _this$calETA2;
342
343 if (valid(value)) this.value = value;
344 this.now = Date.now();
345 (_this$calETA2 = this.calETA) === null || _this$calETA2 === void 0 ? void 0 : _this$calETA2.update(this); // add new value; recalculate eta
346
347 if (this.reachLimit) this.end = this.now;
348 return this;
349 }
350
351 stop(value) {
352 if (valid(value)) this.value = value;
353 this.end = Date.now();
354 return this;
355 }
356
357}
358
359class Escape {
360 constructor(conf) {
361 this.ctx = conf.ctx ?? {};
362 this.arg = conf.arg ?? null;
363 this.fn = conf.fn.bind(this.ctx, this.arg);
364 this.instant = conf.instant ?? true;
365 this.on = false;
366 }
367
368 static build(conf) {
369 return new Escape(conf);
370 }
371
372 get active() {
373 return this.on;
374 }
375
376 async loop(ms) {
377 this.on = true;
378 if (!this.fn) return void 0;
379 if (this.instant) this.fn();
380
381 for await (const _ of setInterval(ms)) {
382 if (!this.on) break;
383 await this.fn();
384 }
385 }
386
387 stop() {
388 return this.on = false;
389 }
390
391}
392
393IO$1.build({});
394
395class IO extends IO$1 {
396 /** @type {boolean} line wrapping enabled */
397 lineWrap = true;
398 /** @type {number} current, relative y position */
399
400 offset = 0;
401 /**
402 *
403 * @param {Config|object} configs
404 * @param {ReadStream} configs.input
405 * @param {WriteStream} configs.output
406 * @param {boolean} [configs.lineWrap]
407 * @param {number} [configs.offset]
408 *
409 */
410
411 constructor(configs) {
412 super(configs);
413 this.offset = configs.offset ?? 0;
414
415 if (!this.isTTY) {
416 console.error('>> [baro:IO] stream is not tty. baro:IO functions may not work.');
417 }
418 }
419
420 static build(configs) {
421 return new IO(configs);
422 } // tty environment
423
424
425 get isTTY() {
426 return this.output.isTTY;
427 }
428
429 get excessive() {
430 return this.offset > this.height;
431 } // CSI n G CHA Cursor Horizontal Absolute Moves the cursor to column n (default 1).
432 // write output
433 // clear to the right from cursor
434
435
436 writeOff(text) {
437 this.output.write(cursor.charTo(0) + text + clear.RIGHT_TO_CURSOR);
438 }
439
440 saveCursor() {
441 this.output.write(cursor.SAVE);
442 } // save cursor position + settings
443
444
445 restoreCursor() {
446 this.output.write(cursor.RESTORE);
447 } // restore last cursor position + settings
448
449
450 showCursor(enabled) {
451 this.output.write(enabled ? cursor.SHOW : cursor.HIDE);
452 } // show/hide cursor
453
454
455 cursorTo(x, y) {
456 this.output.write(cursor.goto(x, y));
457 } // change cursor position: rl.cursorTo(this.output, x, y)
458 // change relative cursor position
459
460
461 moveCursor(dx, dy) {
462 if (dy) this.offset += dy; // store current position
463
464 if (dx || dy) rl.moveCursor(this.output, dx, dy); // move cursor relative
465 }
466
467 offsetLines(offset) {
468 const cursorMovement = offset >= 0 ? cursor.down(offset) : cursor.up(-offset);
469 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
470 } // reset relative cursor
471
472
473 resetCursor() {
474 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
475
476 this.offset = 0; // reset counter
477 }
478
479 clearRight() {
480 rl.clearLine(this.output, 1);
481 } // clear to the right from cursor
482
483
484 clearLine() {
485 rl.clearLine(this.output, 0);
486 } // clear the full line
487
488
489 clearDown() {
490 rl.clearScreenDown(this.output);
491 } // clear everything beyond the current line
492 // add new line; increment counter
493
494
495 nextLine() {
496 this.output.write(LF);
497 this.offset++;
498 } // create new page, equivalent to Ctrl+L clear screen
499
500
501 nextPage() {
502 this.output.write(clear.ENTIRE_SCREEN + cursor.goto(0, 0)); // this.offset = 0
503 } // store state + control line wrapping
504
505
506 setLineWrap(enabled) {
507 this.output.write(enabled ? decset.WRAP_ON : decset.WRAP_OFF);
508 }
509
510} // Baro constructor
511
512
513class Baro {
514 config;
515 format;
516 states = [];
517 escape = Escape.build({
518 fn: this.#renderStates,
519 ctx: this,
520 arg: this.states
521 });
522 #locker = null;
523 offset = 0;
524 /**
525 *
526 * @param {Config} config
527 * @param {Layout} layout
528 */
529
530 constructor(config, layout) {
531 this.config = config;
532 this.layout = layout;
533 this.io = IO.build(this.config);
534 }
535
536 static build(config, layout) {
537 config = Config.build(config);
538 layout = Layout.build(layout);
539 return new Baro(config, layout);
540 }
541
542 get active() {
543 return this.escape.active;
544 }
545
546 get forceRedraw() {
547 return this.config.forceRedraw || this.config.noTTYOutput && !this.io.isTTY; // force redraw in noTTY-mode!
548 }
549
550 async start() {
551 const {
552 io
553 } = this;
554 io.input.resume();
555 if (this.config.hideCursor) io.showCursor(false); // hide the cursor ?
556
557 if (!this.config.lineWrap) io.setLineWrap(false); // disable line wrapping ?
558 // this.io.output.write('\f')
559
560 const height = this.io.height;
561 this.io.output.write(cursor.nextLine(height) + cursor.prevLine(height)); // this.io.output.write(scroll.down(height))
562 // const [ x, y ] = await io.asyncCursorPos()
563 // console.log('x', x, 'y', y, 'offset', this.offset, 'states', this.states.length, 'height', io.height)
564 // if (x + this.states.length >= io.height) {
565 // io.output.write('\f')
566 // // io.nextPage()
567 // }
568 // WARNING: intentionally call loop without await
569
570 this.escape.loop(this.config.throttle); // initialize update timer
571 }
572 /**
573 * add a new bar to the stack
574 * @param {State|object} state // const state = new State(total, value, this.config.eta)
575 * @returns {State|object}
576 */
577
578
579 async append(state) {
580 if (this.#locker) await this.#locker; // console.debug('>>', state.agent, 'waiting for occupy')
581
582 const taskPromise = Promise.resolve().then(async () => {
583 state.last = Number.NEGATIVE_INFINITY;
584 this.states.push(state);
585 if (!this.escape.active && this.states.length) await this.start();
586 });
587 this.#locker = taskPromise.then(() => this.#locker = null);
588 return state;
589 } // remove a bar from the stack
590
591
592 remove(state) {
593 const index = this.states.indexOf(state); // find element
594
595 if (index < 0) return false; // element found ?
596
597 this.states.splice(index, 1); // remove element
598
599 this.io.nextLine();
600 this.io.clearDown();
601 return true;
602 }
603
604 async stop() {
605 this.escape.stop(); // stop timer
606
607 if (this.config.hideCursor) {
608 this.io.showCursor(true);
609 } // cursor hidden ?
610
611
612 if (!this.config.lineWrap) {
613 this.io.setLineWrap(true);
614 } // re-enable line wrapping ?
615
616
617 if (this.config.autoClear) {
618 this.io.resetCursor(); // reset cursor
619
620 this.io.clearDown();
621 } // clear all bars or show final progress
622 else {
623 // for (let state of this.states) { state.stop() }
624 await this.#renderStates(this.states);
625 }
626
627 this.io.input.pause();
628 }
629
630 async #renderStates(states) {
631 const {
632 io
633 } = this;
634 const height = io.height - 1;
635 const [x, y] = await io.asyncCursorPos(); // if (!this.busy && ( x + states.length > height )) {
636 // io.nextPage()
637 // this.offset = 0
638 // }
639 // else {
640 // this.busy = true
641 //
642 // }
643
644 io.offsetLines(-Math.min(this.offset, height)); // reset cursor
645
646 this.offset = 0;
647
648 if (height) {
649 for (const state of states.slice(-height)) {
650 if (this.forceRedraw || state.value !== state.last) {
651 io.writeOff(`CURSOR (${x}, ${y}) OFFSET (${this.offset}) TERM (${io.size}) ` + this.layout.format(state));
652 state.last = state.value;
653 }
654
655 io.nextLine();
656 this.offset++;
657 }
658 }
659
660 if (this.config.autoStop && states.every(state => state.reachLimit)) {
661 await this.stop();
662 } // stop if autoStop and all bars stopped
663
664 }
665
666}
667
668class Spin {
669 max;
670 value;
671 width;
672 step;
673
674 constructor(max, width, step) {
675 this.max = max;
676 this.width = width;
677 this.step = step;
678 this.value = 0;
679 }
680
681 static build(max, width, step) {
682 return new Spin(max, width, step);
683 }
684
685 next() {
686 this.value += this.step;
687 if (this.value >= this.max) this.value -= this.max;
688 return this;
689 }
690
691 get sequel() {
692 let next = this.value + this.width;
693 if (next > this.max) next -= this.max;
694 return next;
695 }
696
697 get record() {
698 const {
699 value,
700 sequel
701 } = this;
702
703 if (value <= 0 || value >= this.max) {
704 const x = this.width;
705 const y = this.max - this.width;
706 return [0, 0, x, y];
707 } else {
708 if (value < sequel) {
709 const x = value - 1;
710 const y = this.width;
711 const z = this.max - this.width - value + 1;
712 return [0, x, y, z];
713 } else {
714 const x = sequel;
715 const y = value - sequel - 1;
716 const z = this.max - value + 1;
717 return [x, y, z, 0];
718 }
719 }
720 }
721
722 renderBar([bar, spc]) {
723 const {
724 value,
725 sequel
726 } = this;
727
728 if (value <= 0 || value >= this.max) {
729 const x = this.width;
730 const y = this.max - this.width;
731 return bar.slice(0, x) + spc.slice(0, y);
732 } else {
733 if (value < sequel) {
734 const x = value;
735 const y = this.width;
736 const z = this.max - this.width - value;
737 return spc.slice(0, x) + bar.slice(0, y) + spc.slice(0, z);
738 } else {
739 const x = sequel;
740 const y = value - sequel;
741 const z = this.max - value;
742 return bar.slice(0, x) + spc.slice(0, y) + bar.slice(0, z);
743 }
744 }
745 }
746
747}
748
749const CHARSET_SHADE = [`█`, `░`]; // \u2588 \u2591
750
751const CHARSET_RECT = [`■`, ` `]; // \u25A0
752
753const CHARSET_LEGACY = [`=`, `-`];
754export { Baro, CHARSET_LEGACY, CHARSET_RECT, CHARSET_SHADE, Config, ETA, Escape, Layout, Spin, State };