152 lines
3.8 KiB
TypeScript
152 lines
3.8 KiB
TypeScript
|
import ansi from 'ansi-escapes';
|
||
|
import { error, success } from './console.ts';
|
||
|
import { flushStderr, writeToStderr } from './internal.ts';
|
||
|
|
||
|
const widgets: Widget[] = [];
|
||
|
let widgetLineCount = 0;
|
||
|
let widgetTimer: Timer | undefined;
|
||
|
let redrawingThisTick = false;
|
||
|
|
||
|
const kInternalUpdate = Symbol('internalUpdate');
|
||
|
const kInternalGetText = Symbol('internalGetText');
|
||
|
|
||
|
function onExit() {
|
||
|
errorAllWidgets('widget alive while process exiting');
|
||
|
writeToStderr(ansi.cursorShow);
|
||
|
flushStderr();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* A Log Widget is a piece of log content that is held at the bottom of the
|
||
|
* console log, and can be animated/dynamically updated. It is used to create
|
||
|
* spinners, progress bars, and other rich visuals.
|
||
|
*/
|
||
|
export abstract class Widget {
|
||
|
constructor() {
|
||
|
widgets.push(this);
|
||
|
|
||
|
if (!widgetTimer) {
|
||
|
writeToStderr(ansi.cursorHide);
|
||
|
widgetTimer = setInterval(redrawWidgetsNoWait, 1000 / 60);
|
||
|
widgetTimer?.unref?.();
|
||
|
process.on('exit', onExit);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Returns a string of what the widget looks like. Called 15 times per second
|
||
|
* to allow for smooth animation. The value passed to now is the result of
|
||
|
* `performance.now`.
|
||
|
*/
|
||
|
abstract format(now: number): string;
|
||
|
/**
|
||
|
* The current FPS of the widget. If this is set to 0, the widget will not
|
||
|
* automatically update, and you must call `update`.
|
||
|
*/
|
||
|
abstract fps: number;
|
||
|
|
||
|
/** Removes this widget from the log. */
|
||
|
stop(finalMessage?: string) {
|
||
|
const index = widgets.indexOf(this);
|
||
|
if (index === -1) {
|
||
|
return;
|
||
|
}
|
||
|
widgets.splice(index, 1);
|
||
|
redrawWidgetsSoon();
|
||
|
if (finalMessage) {
|
||
|
writeToStderr(finalMessage + '\n');
|
||
|
}
|
||
|
if (widgets.length === 0) {
|
||
|
clearInterval(widgetTimer);
|
||
|
process.removeListener('exit', onExit);
|
||
|
widgetTimer = undefined;
|
||
|
writeToStderr(ansi.cursorShow);
|
||
|
redrawWidgetsNoWait();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** Forces a redraw to happen immediately. */
|
||
|
protected redraw() {
|
||
|
this.#nextUpdate = 0;
|
||
|
redrawWidgetsSoon();
|
||
|
}
|
||
|
|
||
|
#nextUpdate = 0;
|
||
|
#text = '';
|
||
|
#newlines = 0;
|
||
|
|
||
|
[kInternalUpdate](now: number) {
|
||
|
if (now > this.#nextUpdate) {
|
||
|
this.#nextUpdate = this.fps === 0 ? Infinity : now + 1000 / this.fps;
|
||
|
const text = this.format(now);
|
||
|
if (text !== this.#text) {
|
||
|
this.#text = text + '\n';
|
||
|
this.#newlines = text.split('\n').length;
|
||
|
}
|
||
|
return true;
|
||
|
}
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
[kInternalGetText]() {
|
||
|
widgetLineCount += this.#newlines;
|
||
|
return this.#text;
|
||
|
}
|
||
|
|
||
|
/** Remove this widget with a success message. */
|
||
|
success(message: string) {
|
||
|
success(message);
|
||
|
this.stop();
|
||
|
}
|
||
|
|
||
|
/** Remove this widget with a failure message. */
|
||
|
error(message: string | Error) {
|
||
|
error(message);
|
||
|
this.stop();
|
||
|
}
|
||
|
|
||
|
get active() {
|
||
|
return widgets.includes(this);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
export function redrawWidgetsSoon() {
|
||
|
if (widgetLineCount) {
|
||
|
writeToStderr(
|
||
|
ansi.eraseLine + (ansi.cursorUp(1) + ansi.eraseLine).repeat(widgetLineCount) + '\r'
|
||
|
);
|
||
|
widgetLineCount = 0;
|
||
|
redrawingThisTick = true;
|
||
|
process.nextTick(redrawWidgetsNoWait);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function redrawWidgetsNoWait() {
|
||
|
redrawingThisTick = false;
|
||
|
const now = performance.now();
|
||
|
const hasUpdate = widgets.filter(widget => widget[kInternalUpdate](now)).length > 0;
|
||
|
|
||
|
if (hasUpdate || widgetLineCount === 0) {
|
||
|
redrawWidgetsSoon();
|
||
|
writeToStderr(widgets.map(widget => widget[kInternalGetText]()).join(''));
|
||
|
}
|
||
|
flushStderr();
|
||
|
}
|
||
|
|
||
|
export function errorAllWidgets(reason: string) {
|
||
|
for (const w of widgets) {
|
||
|
if ('text' in w) {
|
||
|
w.error((w as any).text + ` (due to ${reason})`);
|
||
|
} else {
|
||
|
w.stop();
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/** Writes raw line of text without a prefix or filtering. */
|
||
|
export function writeLine(message = '') {
|
||
|
redrawWidgetsSoon();
|
||
|
writeToStderr(message + '\n');
|
||
|
if (!redrawingThisTick) flushStderr();
|
||
|
}
|