UNPKG

@kwaeri/progress

Version:

The @kwaeri/progress component module of the @kwaeri/user-executable framework.

433 lines 15.5 kB
/** * 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