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(); }