@kwaeri/progress
Version:
The @kwaeri/progress component module of the @kwaeri/user-executable framework.
433 lines • 15.5 kB
JavaScript
/**
* SPDX-PackageName: kwaeri/progress
* SPDX-PackageVersion: 0.6.0
* SPDX-FileCopyrightText: © 2014 - 2022 Richard Winters <kirvedx@gmail.com> and contributors
* SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception OR MIT
*/
'use strict';
import { Spinners } from './assets/spinners.mjs';
import { Console } from '@kwaeri/console';
import { kdt } from '@kwaeri/developer-tools';
import debug from 'debug';
// DEFINES
const _ = new kdt();
const _c = new Console({ color: false, background: false, decor: [] });
/* Configure Debug module support */
const DEBUG = debug('nkm:progress');
/**
* Progress
*
* This class provides the methods required to effectively manipulate terminal/console output
* such that we can display a progress bar while presenting end-users with information.
*/
export class Progress {
/**
* @var { any }
*/
id;
/**
* @var { string }
*/
type;
/**
* @var { boolean }
*/
spinner;
/**
* @var { boolean }
*/
percentage;
/**
* @var { boolean }
*/
active;
/**
* @var { number }
*/
total;
/**
* @var { number }
*/
current;
/**
* @var { string[] }
*/
spinnerFrames;
/**
* @var { number }
*/
spinnerInterval;
/**
* @var { number }
*/
frame;
/**
* @var { number }
*/
barLength;
/**
* @var { number }
*/
preferredBarLength;
/**
* @var { number }
*/
columns;
/**
* @var { string }
*/
notification;
/**
* @var { string }
*/
stamp;
/**
* @var { Intl.DateTimeFormatOptions }
*/
timeOptions = { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false };
/**
* Class constructor
*
* @param { number } barLength The total length of the progress bar. Defaults to 25.
*
* @returns { void }
*/
constructor(options = { type: "bar", barLength: 25, spinner: true, spinAnim: "dots", percentage: false }) {
// Initialize our controls:
this.id = null;
this.active = false;
this.type = options?.type || "bar";
this.spinner = options?.spinner || true;
this.percentage = options?.percentage || false;
this.preferredBarLength = options?.barLength || 25;
this.barLength = this.getBarLength(this.preferredBarLength);
this.spinnerFrames = options.spinAnim ? Spinners[options.spinAnim].frames : Spinners["dots"].frames;
this.spinnerInterval = options.spinAnim ? Spinners[options.spinAnim].interval : Spinners["dots"].interval;
this.frame = 0;
this.notification = "";
}
/**
* A method to intialize (start) a new progress bar
*
* @param { number } total The total that represents a completed process
*
* @returns { void }
*/
init(current = 0, total = 100) {
// Allow the caller to set the current, but default
// to 0 otherwise (the expected value):
this.current = current;
// The total will almost always be 100:
this.total = total;
// Initially we will not have a notification, it can be set later:
this.notification = "Initializing...";
// Activate the progress bar:
this.active = true;
// Begin rendering:
this.render();
}
/**
* Method to update current progress and trigger the progress bar to be drawn
*
* @param { number } current The current progress value.
*
* @returns { void }
*/
update(current) {
// This method is intended to only update progress level with out
// bothering the notification at all (call notify if you want to
// change the notice to the end user).
//
// Historically, It also served as a method to draw the progress
// bar - though this facility is now loop-oriented:
if (current)
this.current = current;
// Draw the progress bar (saved for reference)
// this.draw();
}
/**
* Method to update the notice to the end-user displayed with the progress bar
* and triggers the progress bar to be drawn
*
* @param { string } notice The string notice to display with the progress bar
*
* @return { void }
*/
notify(notice = "") {
// This method is intended to allow for a clear of notice with out
// having to pass any arguments, but let's ensure it's at least a
// string - even the empty string it could be. It also serves as a
// method to draw the progress bar:
if (notice)
this.notification = notice;
// Trigger the progress bar to draw:
//this.draw();
}
/**
* Method to update the progress level of the progress bar, the notice displayed
* with the progress bar, and to trigger the progress bar to be drawn.
*
* @param { number } current The current level of progress
* @param { string } notice The string notice to display with the progress bar
*
* @returns { void }
*/
updateAndNotify(current, notice = "") {
// Like notify, this method is intended to allow for a clear of notice with
// out having to pass anything for 'notice'. However, let's Ensure notice
// amounts to something - even an empty string:
if (notice)
this.notification = notice;
// Then hand off the process to update():
this.update(current);
}
/**
* Does a final draw after stopping the render loop
*
* @param none
*
* @returns { void }
*/
complete() {
this.stop();
this.update(100);
this.draw();
}
/**
* A handler which implements a basic ServiceEvent procedure
*
* @param { ServiceEventBits } data ServiceEventBits
*
* @returns { void }
*
* As an example:
*
* ```typescript
* serviceProvider.updateProgress({
* progressLevel: 25,
* notice: "Currently processing x of y.",
* log: "w of y processed successfully."
* });
* ```
*/
handler(data) {
const { progressLevel, notice, log, logType } = data;
if (log)
this.log(log, logType);
if (progressLevel !== undefined &&
progressLevel !== null)
if (progressLevel >= 0) {
if (notice)
this.updateAndNotify(progressLevel, notice);
else
this.update(progressLevel);
}
else {
if (notice)
this.notify(notice);
}
if (progressLevel === -1)
this.complete();
}
/**
* Returns a progress bar handler bound to the instance of the progress bar
* object it controls.
*
* @returns { ( data: ServiceEventBits ) => void } A bound reference to Progress::handler.
*/
getHandler() {
return this.handler.bind(this);
}
/**
* Method to deactivate (stop) the progress bar (rendering loop)
*
* @param { void }
*
* @returns { void }
*/
stop() {
clearInterval(this.id);
this.id = null;
this.active = false;
}
/**
* Method to render the progress bar in the terminal. By default this method
* starts a rendering loop, but boolean true can be passed to force a single
* render that does not additionally set a timeout to support facilities
* such as `log()` with out breaking the display.
*
* @param { boolean } immediate Flag which determines whether to start a rendering
* loop or to immediately render a single time
*
* @returns { void }
*/
render(immediate = false) {
if (this.active && !this.id)
this.id = setInterval(this.draw.bind(this), this.spinnerInterval);
}
/**
* Method to draw a progress bar
*
* @param { void }
*
* @returns { void }
*/
draw() {
const { stdout } = process;
stdout.clearLine();
stdout.cursorTo(0);
stdout.write(this.getBuffer());
}
/**
* Method to get the progress bar display buffer
*
* @param { void }
*
* @returns { string } The string buffer for a terminal-based progress bar display
*/
getBuffer() {
// Update the column count and bar length in case of manual manipulation
// of columns since instantiation:
this.barLength = this.getBarLength(this.preferredBarLength);
// Calculate the filled and empty bar lengths:
let currentProgress = +(this.current / this.total), filledLength = +(currentProgress * this.barLength).toFixed(0), emptyLength = +(this.barLength - filledLength);
// Prepare the formatted characters required to represent our progress bar:
let filled = this.getBar(filledLength, "\u2588", "white"), // this.getBar( filledLength, " ", "white" ), // "\u2588" █
empty = this.getBar(emptyLength, "\u2591", "black"), //this.getBar( emptyLength, "·", "black" ), // "\u2591" ░
//empty = this.getBar( emptyLength, "-", "black" ),
spinner = (this.current < this.total) ? this.getSpinner() : "\u2713", // "\u2713" ✓
percentage = +(currentProgress * 100).toFixed(2);
_c.normalize();
return (_c.decor(["bright"]).color("red").buffer('[').dump() + `${filled}${empty}` + _c.decor(["bright"]).color("red").buffer("]").dump() +
((this.percentage) ? ` | ${percentage}%` : '') +
((this.spinner) ? ` ${spinner}` : '') +
_c.normalize().buffer(` ${this.notification}`).dump()).substring(0, this.columns);
}
/**
* Method to set the column count and barlength within the terminal that the
* progress bar will be displayed in (allows for componesation of manual
* adjustment of the terminal window size in a desktop environment)
*
* @param { number } preferredBarLength The preferred length of the bar (set at instantiation, defaults to 25)
*
* @returns { number } The adjusted bar length, set to fit within the available horizontal space of the terminal
* with the preferredBarLength as a maximum - while considering any wrappings.
*/
getBarLength(preferredBarLength = 25) {
// First get the number of columns visible:
this.columns = process.stdout.columns;
// The bar length will always be within the number of columns being displayed,
// but may be set anywhere upwards of the maximum columns visible, defaulting
// to 25 if not specified and possible.
//
// The bar is wrapped by square brackets - and so an additional 2 characters
// are considered in the check for available space, and ensured to exist for
// the square brackets to be drawn
//
// There's a potential spinner as well, which would add an additional 2
// characters that must be reserved.
const ec = (this.spinner) ? 4 : 2;
//this.barLength = ( this.columns >= preferredBarLength ) ? preferredBarLength : ( ( this.columns >=25 ) ? 25 : this.columns );
//return this.barLength;
return (this.columns >= (preferredBarLength + ec)) ? preferredBarLength : ((this.columns >= (25 + ec)) ? 25 : (this.columns - ec));
}
/**
* Method which prepares a string representation of a portion of a progress
* bar that is to be rendered within a terminal or console window
*
* @param { number } length The number of times the current character should be written
* @param { string } char The current character that should be repeated
* @param { string } color The color of the current progress bar portion
*
* @returns { string } The prepared and buffered string representing the progress bar portion requested
*/
getBar(length, character, color) {
let portion = "";
for (let i = 0; i < length; i++) {
portion += character;
}
return _c.background(color).buffer(portion).dump();
}
/**
* Method to get the current spinner state to render.
*
* @param { void }
*
* @returns { string } The string buffer for the spinner
*/
getSpinner() {
// Ensure a loop of frames:
if (this.frame >= this.spinnerFrames.length)
this.frame = 0;
// Return the buffer:
return this.spinnerFrames[this.frame++];
}
/**
* Method to add information to our progres output
*
* @param { string } message The string message to present to the user
*
* @returns { Progress } Returns this to allow chaining
*/
log(message, type = 0) {
// First clear the line and set the cursor to 0 (our progress bar has been drawn):
process.stdout.clearLine();
process.stdout.cursorTo(0);
// We'll determine the log type flag here. We use a switch statement
// because they, unlike an if/else control flow, simply enter on
// the first matching case and break if directed, instead of testing
// all conditions. This makes it more efficient than other control
// flow patterns.
//
// The most common type of log message well be an informative one, so
// we'll stack that in our switch statement first:
let color = "black", background = "yellow", term = "INFO", normalized = true, logColor = ["red", false];
switch (type) {
// INFO
case 0:
break;
// ERROR
case 1:
background = "red";
term = "ERROR";
normalized = false;
break;
// IMPORTANT
case 2:
color = "black";
background = "cyan";
term = "IMPORTANT";
break;
// CRITICAL
case 3:
color = "white";
background = "red";
term = "CRITICAL";
normalized = false;
logColor = ["white", "red"];
break;
}
// Then act like a console.log():
process.stdout.write(// Each line prepares:
_c.decor(["bright"]).background("black").color("white").buffer(this.getStamp()) // A stamp for us!
.normalize().buffer(" ") // A space to separate the next component
.decor(["bright"]).background(background).color(color).buffer(term).dump() + // A stamp for log type (? TBA)
((normalized) ? _c.normalize() : _c.color(logColor[0]).background(logColor[1])) // Prep the actual log data
.buffer(` ${message}\n`).dump() // The actual log data
);
// Then redraw our bar (so it seems as though information is being dumped
// behind our progress bar):
this.draw();
// Return with { this } to allow for chaining off this method:
return this;
}
/**
* Method to get a timestamp for logging purposes
*
* @returns { string } A timestamp in a log format
*/
getStamp() {
return `[${new Date().toLocaleTimeString("en-us", this.timeOptions)}]`;
}
}
//# sourceMappingURL=progress.mjs.map