console/src/Widget.ts

152 lines
3.8 KiB
TypeScript
Raw Normal View History

2025-03-14 20:24:43 -07:00
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();
}