UNPKG

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