From 33bef63518232569f6494e9c8a4c48c9db3ec683 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Sat, 6 Sep 2025 03:52:38 -0700 Subject: [PATCH] feat(lib): new library "log" this replaces my `console` library as `lib/log`: - log scoping greatly simplified `log.Scope`. each scope has separate `info`, `warn`, and `error`, while still being a callable value - widget system rebuilt using an interface instead of extending a class - replace `Progress` and `Spinner` with unified progress module - api with disposable: `using node = progress.start(title)` - nesting for subtasks by calling `node.start()` - inline logging and customizable display - these three components can be initialized and used headlessly. in the future, it will be able to communicate status over ipc and the network - no external dependencies. optional dependency on `node:process`. --- lib/jsr.json | 3 + lib/string.ts | 156 ++++++++++++++++ lib/term.ts | 102 +++++++++++ lib/term/ansi.ts | 94 ++++++++++ lib/term/headless.ts | 313 ++++++++++++++++++++++++++++++++ lib/term/progress.sample.ts | 71 ++++++++ lib/term/progress.ts | 346 ++++++++++++++++++++++++++++++++++++ lib/ts.ts | 11 ++ 8 files changed, 1096 insertions(+) create mode 100644 lib/string.ts create mode 100644 lib/term.ts create mode 100644 lib/term/ansi.ts create mode 100644 lib/term/headless.ts create mode 100644 lib/term/progress.sample.ts create mode 100644 lib/term/progress.ts create mode 100644 lib/ts.ts diff --git a/lib/jsr.json b/lib/jsr.json index ee466519a5759fea16122a7c042094d0d4c34855..5f7de769bba4746ead3b1c34026628cf6bf79e8f 100644 --- a/lib/jsr.json +++ b/lib/jsr.json @@ -3,5 +3,8 @@ "version": "1.0.0", "license": "MIT", "exports": { + "./term": "./term.ts", + "./term/ansi": "./term/ansi.ts", + "./term/headless": "./term/headless.ts" } } diff --git a/lib/string.ts b/lib/string.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b82a0241ea2bd6c454642d08f5c8ae416684dd3 --- /dev/null +++ b/lib/string.ts @@ -0,0 +1,156 @@ +// TODO: this function is not benchmarked +export function countNewlines(str: string) { + let count = 0; + for (let i = 0, { length } = str; i < length; i += 1) { + if (str[i] === "\n") count += 1; + } + return count; +} + +export function widthInTerminal(str: string) { + let width = 0; + if (str.includes("\x1b")) str = str.replace(ansiEscapes, ""); + for (let i = 0, { length } = str; i < length; i += 1) { + const code = str.charCodeAt(i); + if (code < 32) continue; + if (code < 127) { + width += 1; + continue; + } + for (const { segment } of segmenter.segment(str.slice(i))) { + if (segment.length === 1) { + if (code < 32) continue; + if (code < 127) { + width += 1; + continue; + } + } + width += segmentWidth(segment); + } + return width; + } + return width; +} + +export function trimToWidth(str: string, columns: number) { + let width = 0; + let ansi = false; + for (let i = 0, { length } = str; i < length; i += 1) { + const code = str.charCodeAt(i); + if (code < 32) { + if (code === 0x1b) { + const esc = str.slice().match(ansiEscapes); + if (esc) i += esc[0].length - 1, ansi = true; + } + continue; + } + if (code < 127) { + width += 1; + if (width >= columns) { + const result = str.slice(0, i + 1); + return ansi ? result + "\x1b[0m" : result; + } + continue; + } + // non-ascii path + let out = str.slice(0, i); + str = str.slice(i); + const segments = segmenter.segment(str); + { + const { segment } = segments.containing(0); + width += segmentWidth(segment); + if (width > columns) return ansi ? out + "\x1b[0m" : out; + out += segment; + i = segment.length; + } + for ({ length } = str; i < length; i += 1) { + const code = str.charCodeAt(i); + if (code === 0x1b) { + const esc = str.slice(i).match(ansiEscapes); + if (esc) { + i += esc[0].length - 1, ansi = true; + out += esc[0]; + continue; + } + } + const { segment } = segments.containing(i); + width += segmentWidth(segment); + if (width > columns) return ansi ? out + "\x1b[0m" : out; + out += segment; + } + return out; + } + return str; +} + +// derived from sindresorhus's wonderful `string-width` +// https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js +// https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js +// MIT license +const segmenter = /* @__PURE__ */ new Intl.Segmenter(); +const ansiEscapes = + /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/\#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/\#&.:=?%@~_]*)*)?(?:\u0007|\u001B\u005C|\u009C))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g; +const zeroWidthClusterRegex = + /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; +const leadingNonPrintingRegex = + /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; +const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; +function segmentWidth(segment: string) { + if (zeroWidthClusterRegex.test(segment)) return 0; + if (rgiEmojiRegex.test(segment)) return 2; + + // deno-fmt-ignore + const list = [ + 0x3000, 0x231A, 0x231B, 0x2329, 0x232A, 0x23F0, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615, + 0x267F, 0x2693, 0x26A1, 0x26AA, 0x26AB, 0x26BD, 0x26BE, 0x26C4, 0x26C5, 0x26CE, 0x26D4, + 0x26EA, 0x26F2, 0x26F3, 0x26F5, 0x26FA, 0x26FD, 0x2705, 0x270A, 0x270B, 0x2728, 0x274C, + 0x274E, 0x2757, 0x27B0, 0x27BF, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55, 0x1AFFD, 0x1AFFE, 0x1B132, + 0x1B155, 0x1F004, 0x1F0CF, 0x1F18E, 0x1F250, 0x1F251, 0x1F3F4, 0x1F440, 0x1F57A, 0x1F595, + 0x1F596, 0x1F5A4, 0x1F6CC, 0x1F6EB, 0x1F6EC, 0x1F7F0, 0x1FAC8, + ]; + const x = segment.replace(leadingNonPrintingRegex, "").codePointAt(0) ?? 0; + return 1 + +( + // full + x >= 0xFF01 && x <= 0xFF60 || x >= 0xFFE0 && x <= 0xFFE6 || + // wide + x >= 0x1100 && x <= 0x115F || x >= 0x23E9 && x <= 0x23EC || + x >= 0x2630 && x <= 0x2637 || x >= 0x2648 && x <= 0x2653 || + x >= 0x268A && x <= 0x268F || x >= 0x2753 && x <= 0x2755 || + x >= 0x2795 && x <= 0x2797 || x >= 0x2E80 && x <= 0x2E99 || + x >= 0x2E9B && x <= 0x2EF3 || x >= 0x2F00 && x <= 0x2FD5 || + x >= 0x2FF0 && x <= 0x2FFF || x >= 0x3001 && x <= 0x303E || + x >= 0x3041 && x <= 0x3096 || x >= 0x3099 && x <= 0x30FF || + x >= 0x3105 && x <= 0x312F || x >= 0x3131 && x <= 0x318E || + x >= 0x3190 && x <= 0x31E5 || x >= 0x31EF && x <= 0x321E || + x >= 0x3220 && x <= 0x3247 || x >= 0x3250 && x <= 0xA48C || + x >= 0xA490 && x <= 0xA4C6 || x >= 0xA960 && x <= 0xA97C || + x >= 0xAC00 && x <= 0xD7A3 || x >= 0xF900 && x <= 0xFAFF || + x >= 0xFE10 && x <= 0xFE19 || x >= 0xFE30 && x <= 0xFE52 || + x >= 0xFE54 && x <= 0xFE66 || x >= 0xFE68 && x <= 0xFE6B || + x >= 0x16FE0 && x <= 0x16FE4 || x >= 0x16FF0 && x <= 0x16FF6 || + x >= 0x17000 && x <= 0x18CD5 || x >= 0x18CFF && x <= 0x18D1E || + x >= 0x18D80 && x <= 0x18DF2 || x >= 0x1AFF0 && x <= 0x1AFF3 || + x >= 0x1AFF5 && x <= 0x1AFFB || x >= 0x1B150 && x <= 0x1B152 || + x >= 0x1B164 && x <= 0x1B167 || x >= 0x1B170 && x <= 0x1B2FB || + x >= 0x1D300 && x <= 0x1D356 || x >= 0x1D360 && x <= 0x1D376 || + x >= 0x1F191 && x <= 0x1F19A || x >= 0x1F200 && x <= 0x1F202 || + x >= 0x1F210 && x <= 0x1F23B || x >= 0x1F240 && x <= 0x1F248 || + x >= 0x1F260 && x <= 0x1F265 || x >= 0x1F300 && x <= 0x1F320 || + x >= 0x1F32D && x <= 0x1F335 || x >= 0x1F337 && x <= 0x1F37C || + x >= 0x1F37E && x <= 0x1F393 || x >= 0x1F3A0 && x <= 0x1F3CA || + x >= 0x1F3CF && x <= 0x1F3D3 || x >= 0x1F3E0 && x <= 0x1F3F0 || + x >= 0x1F3F8 && x <= 0x1F43E || x >= 0x1F442 && x <= 0x1F4FC || + x >= 0x1F4FF && x <= 0x1F53D || x >= 0x1F54B && x <= 0x1F54E || + x >= 0x1F550 && x <= 0x1F567 || x >= 0x1F5FB && x <= 0x1F64F || + x >= 0x1F680 && x <= 0x1F6C5 || x >= 0x1F6D0 && x <= 0x1F6D2 || + x >= 0x1F6D5 && x <= 0x1F6D8 || x >= 0x1F6DC && x <= 0x1F6DF || + x >= 0x1F6F4 && x <= 0x1F6FC || x >= 0x1F7E0 && x <= 0x1F7EB || + x >= 0x1F90C && x <= 0x1F93A || x >= 0x1F93C && x <= 0x1F945 || + x >= 0x1F947 && x <= 0x1F9FF || x >= 0x1FA70 && x <= 0x1FA7C || + x >= 0x1FA80 && x <= 0x1FA8A || x >= 0x1FA8E && x <= 0x1FAC6 || + x >= 0x1FACD && x <= 0x1FADC || x >= 0x1FADF && x <= 0x1FAEA || + x >= 0x1FAEF && x <= 0x1FAF8 || x >= 0x20000 && x <= 0x2FFFD || + x >= 0x30000 && x <= 0x3FFFD || x >= 0x1B000 && x <= 0x1B122 || + list.includes(x) + ); +} diff --git a/lib/term.ts b/lib/term.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a9a6c46150ef82d8d0ffd5ba651a9b84be4b9d3 --- /dev/null +++ b/lib/term.ts @@ -0,0 +1,102 @@ +/** informational */ +export function info(...args: unknown[]) { + globalLog(...args); +} +/** advisory */ +export function warn(...args: unknown[]) { + globalLog.warn(...args); +} +/** failure */ +export function error(...args: unknown[]) { + globalLog.error(...args); +} + +export interface Scope { + /** default call signature is informational scope */ + (...args: unknown[]): void; + /** informational */ + info(...args: unknown[]): void; + /** advisory */ + warn(...args: unknown[]): void; + /** failure */ + error(...args: unknown[]): void; + + /** prefix */ + name: string; + scoped(name: string): Scope; +} + +/** custom scopes are colored based on the name */ +export function scoped(name: string): Scope { + return globalLog.scoped(name); +} + +/** + * no built-in prefix or formatting. ensures the text does not interweave. + * data will be flushed in the next frame or when drawing is unlocked. + */ +export function writeLine(text: string) { + globalWidgetHost.writeLine(text); +} + +/** + * while locked, no widgets will draw. prefer `writeLine`. + * this lock is not exclusive. + */ +export function getDrawLock(): ts.Dispose { + return globalWidgetHost.getDrawLock(); +} + +/** + * a widget is an interactive display that persists at the end + * of the log. this can be used to implement status bars, progress + * indicators, and other human i/o. only 'format' is required. + */ +export function startWidget(widget: Widget): ts.Dispose { + return globalWidgetHost.startWidget(widget); +} + +export interface Widget { + /** + * return the widget's text. return null to detach the widget. + * may get called more often than the specified `fps`. + * supports color codes but not ansi cursor movements. + */ + format( + now: ReturnType, + focus: boolean, + ): string | null; + /** 'null' to never update (use 'onChange'). defaults to 12 fps */ + fps?: number | null; + /** Subscribe to manual widget updates. Call `rerender` when needed. */ + onChange?(rerender: () => void): () => void; + // /** + // * listen for keyboard events. + // */ + // onKey?(key: string): void; +} + +const globalWidgetHost = /* @__PURE__ */ (() => { + const widget = headless.widgetHost({ + // TODO: consider synchronization and make these async + writeOutput: (string) => process.stdout.write(string), + writeInteractive: (string) => process.stderr.write(string), + now: () => performance.now(), + wait: (ms, cb) => { + const id = setTimeout(cb, ms); + return () => clearTimeout(id); + }, + getSize: () => process.stderr, + }); + process.addListener("beforeExit", () => widget.cancel()); + process.addListener("exit", () => widget.cancel()); + return widget; +})(); +const globalLog = /* @__PURE__ */ headless.logger({ + log: globalWidgetHost, + colors: process.stderr.isTTY, +}); + +import process from "node:process"; +import * as headless from "./term/headless.ts"; +import * as ts from "./ts.ts"; diff --git a/lib/term/ansi.ts b/lib/term/ansi.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d89d906272d80d36d4b09a6a0dd6ea010e844be --- /dev/null +++ b/lib/term/ansi.ts @@ -0,0 +1,94 @@ +export const reset: string = `\x1b[0m`; + +export const fgReset: string = `\x1b[39m`; +export const fgBlack: string = `\x1b[30m`; +export const fgRed: string = `\x1b[31m`; +export const fgGreen: string = `\x1b[32m`; +export const fgYellow: string = `\x1b[33m`; +export const fgBlue: string = `\x1b[34m`; +export const fgPurple: string = `\x1b[35m`; +export const fgCyan: string = `\x1b[36m`; +export const fgWhite: string = `\x1b[37m`; +export const fgBrightBlack: string = `\x1b[90m`; +export const fgBrightRed: string = `\x1b[91m`; +export const fgBrightGreen: string = `\x1b[92m`; +export const fgBrightYellow: string = `\x1b[93m`; +export const fgBrightBlue: string = `\x1b[94m`; +export const fgBrightPurple: string = `\x1b[95m`; +export const fgBrightCyan: string = `\x1b[96m`; +export const fgBrightWhite: string = `\x1b[97m`; +export function fg256(n: number): string { + return `\x1b[38;5;${n}m`; +} +export function fgRgb(r: number, g: number, b: number): string { + return `\x1b[38;2;${r};${g};${b}m`; +} + +export const bgReset: string = `\x1b[49m`; +export const bgBlack: string = `\x1b[40m`; +export const bgRed: string = `\x1b[41m`; +export const bgGreen: string = `\x1b[42m`; +export const bgYellow: string = `\x1b[43m`; +export const bgBlue: string = `\x1b[44m`; +export const bgPurple: string = `\x1b[45m`; +export const bgCyan: string = `\x1b[46m`; +export const bgWhite: string = `\x1b[47m`; +export const bgBrightBlack: string = `\x1b[100m`; +export const bgBrightRed: string = `\x1b[101m`; +export const bgBrightGreen: string = `\x1b[102m`; +export const bgBrightYellow: string = `\x1b[103m`; +export const bgBrightBlue: string = `\x1b[104m`; +export const bgBrightPurple: string = `\x1b[105m`; +export const bgBrightCyan: string = `\x1b[106m`; +export const bgBrightWhite: string = `\x1b[107m`; +export function bg256(n: number): string { + return `\x1b[48;5;${n}m`; +} +export function bgRgb(r: number, g: number, b: number): string { + return `\x1b[48;2;${r};${g};${b}m`; +} + +// cursor management +export function cursorUp(n: number) { + return n ? `\x1b[${n}A` : ""; +} +export const startOfLine: string = "\r"; +export const startOfNextLine: string = "\n"; + +// erasure +export const clearToEndOfLine: string = "\x1b[K"; +export const clearToStartOfLine: string = "\x1b[1K"; +export const clearFullLine: string = "\x1b[2K"; + +// Synchronized Output +// https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 +export const syncStart: string = "\x1b[?2026h"; +export const syncEnd: string = "\x1b[?2026l"; + +export function style(code: string, text: string): string { + code = mergeStyles(code); + const fg = /[\[;][39]/.test(code); + const bg = /[\[;](?:4|10)/.test(code); + return code + text + + mergeStyles((fg ? fgReset : "") + (bg ? bgReset : "")); +} + +/** + * Given a string of multiple style ansi codes, combines all of them. + * Must be used on a string of only ansi style codes. + */ +export function mergeStyles(code: string) { + let styles = []; + while (code.length) { + if (!code.startsWith("\x1b[")) { + throw new Error("invalid style string: " + JSON.stringify(code)); + } + const end = code.indexOf("m"); + if (end === -1) { + throw new Error("invalid style string: " + JSON.stringify(code)); + } + styles.push(code.slice(2, end)); + code = code.slice(end + 1); + } + return `\x1b[${styles.join(";")}m`; +} diff --git a/lib/term/headless.ts b/lib/term/headless.ts new file mode 100644 index 0000000000000000000000000000000000000000..2746f2fa0e106d00e52dd455440687edddf8ec4b --- /dev/null +++ b/lib/term/headless.ts @@ -0,0 +1,313 @@ +// This file is under heavy construction. +// +// Rendering for 'lib/term' is done by specifying all system APIs up front in +// an interface and creating an instance of the widget host. The main file +// fufills this interface with `node:process` and `globalThis`. +// +// Some notes on widget rendering: +// - Never flush immediately (exception for `getDrawLock`), always do it next tick. +// - Maximum of one `wait` call at once. When the expected time suddenly +// shrinks, the timer is rescheduled. +// - When redrawing widget lines, three tricks are done to reduce flickering: +// 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd) +// 2. A simple prefix-based diffing algorithm for skipping unchanged text +// 3. Avoid clearing a line before redrawing it. +// Points 2 and 3 are used for terminals that are slow or have +// + +export interface WidgetHostEnv { + writeInteractive(text: string): void; + writeOutput(text: string): void; + now(): ReturnType; + /** 0ms indicates "one frame" */ + wait(ms: number, cb: () => void): () => void; + getSize(): { columns: number; rows: number }; + // onInput?(write: (bytes: Uint8Array | string) => void): () => void; +} + +/** Not intended to be implemented manually. See `widgetHost` */ +export interface WidgetHost { + /** + * no built-in prefix or formatting. ensures the text does not interweave. + * data will be flushed in the next frame or when drawing is unlocked. + */ + writeLine(text: string): void; + /** + * while locked, no widgets will draw. prefer `writeLine`. + * this lock is not exclusive. + */ + getDrawLock(): ts.Dispose; + /** + * a widget is an interactive display that persists at the end + * of the log. this can be used to implement status bars, progress + * indicators, and other human i/o. only `format` is required. + */ + startWidget(widget: term.Widget): ts.Dispose; + /** stop all widgets and remove all timers. */ + cancel(): void; +} + +interface WidgetState { + frameTime: number; + next: number; + unsub: (() => void) | null; +} + +/** Implement a widget host by providing an environment. */ +export function widgetHost(env: WidgetHostEnv): WidgetHost { + const { writeOutput, writeInteractive, now, wait, getSize } = env; + type Cancel = ReturnType; + + let timer: Cancel | null = null; + + let locks = 0; + let redrawTime = 0; + let lastFlush = 0; + let buffer = ""; + const widgets: term.Widget[] = []; + const internals: WidgetState[] = []; + let lines: string[] = []; + + function redrawCallback() { + timer = null; + redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround + + if (!lines.length && !widgets.length) { + buffer && writeOutput(buffer); + buffer = ""; + return; + } + + const { columns, rows } = getSize(); + let newWidgetLines: string[] = []; + let next = Infinity; + if (widgets[0]) { + for (let w = 0, { length } = widgets; w < length; w += 1) { + const outText = widgets[w].format(lastFlush, false); + if (!outText) { + widgets.splice(w, 1); + internals.splice(w, 1)[0].unsub?.(); + w -= 1; + length -= 1; + continue; + } + const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); + if (rowsLeft === 1) break; + const lines = outText.split("\n").slice(0, rowsLeft); + newWidgetLines.push( + ...lines.map((line) => string.trimToWidth(line, columns - 1)), + ); + + next = Math.min(next, internals[w].frameTime); + } + + newWidgetLines = newWidgetLines.slice(0, rows - 1); + } + + if (next < Infinity) redrawSoon(next); + + if (!newWidgetLines[0]) { + if (lines.length) { + let clearLinesTop = Math.min( + lines.length, + string.countNewlines(buffer), + ); + writeInteractive( + ansi.startOfLine + + // skip up the shared widget space + ansi.cursorUp(lines.length) + + // clear the lines to contain `buffer` + (ansi.clearFullLine + ansi.startOfNextLine) + .repeat(clearLinesTop) + + ansi.cursorUp(clearLinesTop), + ); + lines = []; + } + buffer && writeOutput(buffer); + buffer = ""; + return; + } + + if (buffer) { + // do not perform diffing since the entire screen is moving down + // TODO: should i handle wrapping? + let clearLinesTop = Math.min( + lines.length, + string.countNewlines(buffer), + ); + let clearLinesBottom = Math.min( + lines.length - clearLinesTop, + Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), + ); + writeInteractive( + ansi.syncStart + ansi.startOfLine + + // clear the bottom lines + (clearLinesBottom + ? ansi.cursorUp(1) + + (ansi.clearToEndOfLine + ansi.cursorUp(1)) + .repeat(clearLinesBottom) + : "") + + // skip up the shared widget space + ansi.cursorUp(lines.length - clearLinesBottom) + + // clear the lines to contain `buffer` + (ansi.clearFullLine + ansi.startOfNextLine) + .repeat(clearLinesTop) + + ansi.cursorUp(clearLinesTop), + ); + // then write output lines on standard out + writeOutput(buffer); + writeInteractive( + // the widget text + newWidgetLines.join("\n") + "\n" + + ansi.syncEnd, + ); + } else { + const clearLinesBottom = Math.min( + lines.length, + Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), + ); + writeInteractive( + ansi.syncStart + ansi.startOfLine + + // clear the bottom lines + (clearLinesBottom + ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) + .repeat(clearLinesBottom) + : "") + + // skip up the widget space + ansi.cursorUp(lines.length - clearLinesBottom) + + // the widget text + newWidgetLines.map((x) => + (x.includes("\x1b") && !x.endsWith(ansi.reset) + ? x + ansi.reset + : x) + + ansi.clearToEndOfLine + "\n" + ).join("") + + ansi.syncEnd, + ); + } + lines = newWidgetLines; + + buffer = ""; + } + + function redrawSoon(ms: number) { + if (locks > 0 || (ms === 0 && timer)) return; + const newRedrawTime = now() + ms; + if (timer) { + if (redrawTime < newRedrawTime) return; + timer(); // cancel previous + timer = null; + } + redrawTime = newRedrawTime + 1; + timer = wait(ms, redrawCallback); + } + + function flushAndClear() { + timer?.(); + timer = null; + if (lines.length > 0) { + // clear widgets + } + if (buffer.length > 0) writeOutput(buffer), buffer = ""; + } + + return { + writeLine(chunk) { + buffer += chunk + "\n"; + redrawSoon(0); + }, + getDrawLock() { + if (locks === 0) flushAndClear(); + locks += 1; + return ts.defer(() => { + locks -= 1; + if (locks === 0) { + if (buffer.length > 0) writeOutput(buffer), buffer = ""; + // TODO: reinit widget + } + }); + }, + startWidget(w) { + if (!widgets.includes(w)) { + const state: WidgetState = { + next: 0, + unsub: null, + frameTime: 1000 / (w.fps ?? 0), + }; + widgets.push(w); + internals.push(state); + state.unsub = w.onChange?.(() => { + state.next = 0; + redrawSoon(0); + }) ?? null; + redrawSoon(0); + } + return ts.defer(() => { + const i = widgets.indexOf(w); + widgets.splice(i, 1); + internals.splice(i, 1)[0].unsub?.(); + redrawSoon(0); + }); + }, + cancel() { + flushAndClear(); + }, + }; +} + +export interface LoggerOptions { + /** Provide either a line writer or a log writer. */ + log: { + writeLine: WidgetHost["writeLine"]; + } | { + writeLog: (prefix: string, level: Level, ...args: unknown[]) => void; + }; + colors: boolean; +} + +export function logger(env: LoggerOptions): term.Scope { + const { colors, log } = env; + const levels = colors + ? [ + ansi.style(ansi.fgBlue, "info"), + ansi.style(ansi.fgYellow, "warn"), + ansi.style(ansi.fgRed, "error"), + ] as const + : ["info", "warn", "error"] as const; + const colon = colors ? ansi.style(ansi.fgBrightBlack, ":") + " " : ": "; + const logFn = "writeLog" in log + ? log.writeLog + : ((prefix: string, level: Level, ...args: unknown[]) => { + log.writeLine( + (prefix + ? prefix + (level > 0 ? levels[level] + colon : "") + : (levels[level] + colon)) + util.format(...args), + ); + }); + + function scoped(name: string): term.Scope { + const formatted = name ? name + colon : name; + const fn = logFn.bind(null, formatted, 0) as Partial; + fn.info = fn as term.Scope["info"]; + fn.warn = logFn.bind(null, formatted, 1); + fn.error = logFn.bind(null, formatted, 2); + fn.scoped = createSubScope; + Object.defineProperty(fn, "name", { value: name }); + return fn as term.Scope; + } + + function createSubScope(this: term.Scope, name: string) { + const { name: parent } = this; + return scoped(parent ? parent + "/" + name : name); + } + + return scoped(""); +} + +type Level = 0 | 1 | 2; + +import * as ansi from "./ansi.ts"; +import * as string from "../string.ts"; +import * as ts from "../ts.ts"; +import * as util from "node:util"; +import type * as term from "../term.ts"; diff --git a/lib/term/progress.sample.ts b/lib/term/progress.sample.ts new file mode 100644 index 0000000000000000000000000000000000000000..afd45c8d8bcdc49ecdd566005b32694ae94b2eb2 --- /dev/null +++ b/lib/term/progress.sample.ts @@ -0,0 +1,71 @@ +const root = progress.start("progress node", { estimate: 30 }); +for (let i = 0; i < 10; i += 1) { + { + const subNodes = Array.from( + { length: 4 }, + (_) => root.start("subtask " + random()), + ); + await delay(); + subNodes[0]!.end(); + const n = subNodes[1]!.start("meowing", { estimate: 7 }); + const m = subNodes[1]!.start("purring", { estimate: 7 }); + let w: progress.Node | null = null; + let x: progress.Node | null = null; + let y: progress.Node | null = null; + let z: progress.Node | null = null; + for (let i = 0; i < 14; i += 1) { + if (Math.random() > 0.5 && n.value < n.estimate) { + n.inc(); + } else { + m.inc(); + } + await short(); + if (i === 5) subNodes[3]!.end(); + if (i === 3) { + x = subNodes[2]!.start("other task"); + y = x.start("deeply nested"); + z = y.start("job"); + } + if (i == 8) z?.end(); + if (i === 6) w = y!.start("magic"); + if (i == 12) y!.end(); + if (i === 10) subNodes[3]!.end(); + } + await delay(); + n.inc(); + await delay(); + n.end(); + root.inc(); + subNodes.forEach((x) => x.end()); + } + { + const subNodes = Array.from( + { length: 8 }, + (_) => root.start("subtask with count " + random()), + ); + subNodes.forEach((x) => x.value = 1); + await delay(); + subNodes[0]!.end(); + for (let i = 0; i < 80; i += 1) { + const node = subNodes[Math.floor(Math.random() * 8)]!; + node.inc(); + if (node.value > 10) node.end(); + await short(); + } + await delay(); + subNodes.forEach((x) => x.end()); + } +} + +async function delay() { + await timers.setTimeout(Math.random() * 100 + 200); +} +async function short() { + await timers.setTimeout(Math.random() * 50 + 50); +} +function random() { + return Math.floor(Math.random() * 9999999).toString(32); +} + +import * as progress from "./progress.ts"; +import * as timers from "node:timers/promises"; diff --git a/lib/term/progress.ts b/lib/term/progress.ts new file mode 100644 index 0000000000000000000000000000000000000000..8482ef81440058ab1107781137ca18f86c268431 --- /dev/null +++ b/lib/term/progress.ts @@ -0,0 +1,346 @@ +// This file is under heavy construction. +// +// A progress tree based on my past uses with clover console v3 and the Zig +// Progress API. Pass `Progress` to functions which should track progress, +// calling `.start()` to create child items. Or, multiple top-level progress +// bars can be created with the singleton's `start()` function. +import * as self from "./progress.ts"; +self satisfies Ref; // prove you can use the namespace import as a `Progress` + +/** + * creates a new trackable unit of work as a child of this one. + * when given an estimate, a progress bar is rendered. + */ +export function start(text: string, opts?: StartOptions): Node { + const [state, node] = newNode(text, opts); + globalState.push(state); + state.signal = topLevelSignaler; + if (rerender) rerender(); + else log.startWidget(globalWidget); + return node; +} + +export interface Ref { + /** + * creates a new trackable unit of work as a child of this one. + * when given an estimate, a progress bar is rendered. + */ + start(text: string, opts?: StartOptions): Node; +} + +export interface Node extends Ref { + /** reactive */ + text: string; + /** reactive */ + value: number; + /** reactive */ + estimate: number; + /** + * reactive, default `true`. `false` will hides a bar + printing estimate, + * but preseving auto-end behavior of `inc` + */ + showEstimate: boolean; + /** + * reactive, default `false`. when true, this progress item is hidden if + * there are no children. + */ + passive: boolean; + /** reactive. null means decide based on nested depth + terminal height */ + maxHeight: null | number; + /** reactive. specify sorting for children */ + sortChildren: ((a: SortNode, b: SortNode) => number) | null; + + /** defaults to increasing by 1. hitting estimate calls end */ + inc(value?: number): void; + + /** A scoped logger set to output inline to this item. */ + log: log.Scope; + + /** end this Node or RootNode. ending collapses children */ + end(): void; + // /** signal that there will be a failure without ending the node */ + // pendingFail(): void; + // /** end this node with a failure */ + // endFail(): void; + + [Symbol.dispose](): void; +} + +export interface StartOptions { + estimate?: number | undefined | null; +} + +/** a node that satisfies the api */ +const nullNode: Node = + (function start(text: string, { estimate }: StartOptions = {}): Node { + return { + start, + text, + value: 0, + estimate: estimate ?? 0, + showEstimate: true, + passive: false, + maxHeight: null, + sortChildren: null, + inc() { + this.value += 1; + }, + log: log.scoped(""), + end() {}, + [Symbol.dispose]() {}, + }; + })("root"); + +export { nullNode as null }; + +type SortNode = + & Pick< + Node, + | "text" + | "value" + | "estimate" + | "passive" + | "showEstimate" + | "sortChildren" + > + & { children: SortNode[] }; + +type Signal = "draw" | "end" | "endFail" | "pendingFail"; +interface State extends SortNode { + logs: string[]; + children: State[]; + parent: State | null; + signal(signal: Signal): void; +} + +function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { + const state: State = { + text, + value: 0, + estimate: estimate ?? 0, + showEstimate: true, + passive: false, + logs: [], + children: [], + parent: null, + sortChildren: null, + signal() { + throw new Error("no signaler"); + }, + }; + function end() { + if (!state.parent) return; + const i = state.parent.children.indexOf(state); + if (i === -1) return; + state.signal("draw"); + state.parent.children.splice(i, 1); + state.parent = null; + } + function mutate() { + const { parent } = state; + parent?.sortChildren && parent.children.sort(parent.sortChildren); + state.signal("draw"); + } + const scope = headless.logger({ + log: { + writeLine(line) { + state.logs.push(...line.split("\n")); + state.signal("draw"); + }, + }, + colors: process.stderr.isTTY, + }); + const binding: Node = { + start(text, opts) { + const [child, node] = newNode(text, opts); + state.children.push(child); + state.sortChildren && state.children.sort(state.sortChildren); + child.parent = state; + child.signal = state.signal; + state.signal("draw"); + return node; + }, + get text() { + return state.text; + }, + set text(value) { + state.text = value; + mutate(); + }, + get value() { + return state.value; + }, + set value(value) { + state.value = value; + mutate(); + }, + get estimate() { + return state.estimate; + }, + set estimate(value) { + state.estimate = value; + mutate(); + }, + get showEstimate() { + return state.showEstimate; + }, + set showEstimate(value) { + state.showEstimate = value; + mutate(); + }, + get passive() { + return state.passive; + }, + set passive(value) { + state.passive = value; + mutate(); + }, + maxHeight: null, + // get maxHeight() { + // return state.maxHeight; + // }, + // set maxHeight(value) { + // state.maxHeight = value; + // mutate(); + // }, + get sortChildren() { + return state.sortChildren; + }, + set sortChildren(value) { + state.sortChildren = value; + value && state.children.sort(value); + state.signal("draw"); + }, + inc(delta = 1) { + state.value += delta; + if (state.estimate > 0 && state.value >= state.estimate) { + end(); + } else { + mutate(); + } + }, + log: scope, + end: () => void end(), + [Symbol.dispose]: () => void end(), + }; + return [state, binding]; +} + +const spinnerFps = 12.5; + +const box = { + tee: "├─ ", + line: "│ ", + langle: "└─ ", +}; +const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; +const fullBar = "█"; +const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + .map((frame) => ansi.style(ansi.fgBlue, frame)); + +let rerender: (() => void) | null = null; +let globalTimer: ts.Interval | null = null; +const globalWidget: log.Widget = { + format(now) { + let out = ""; + for (const top of globalState) { + if (top.passive && top.children.length === 0) continue; + out += renderMainLine(top, now, 0) + "\n"; + out += renderChildren(top, now, []); + } + return out.trimEnd(); + }, + onChange(cb) { + //const globalTimer = setInterval(cb, 1000 / spinnerFps); + //if ("unref" in globalTimer) (globalTimer.unref as () => void)(); + rerender = cb; + return () => { + globalTimer != null && clearInterval(globalTimer); + rerender = null; + }; + }, +}; +const globalState: State[] = []; + +function renderMainLine(state: State, now: number, depth: number) { + if (state.estimate && state.showEstimate) { + return ansi.style( + ansi.bgBrightBlack + ansi.fgBlue, + getUnicodeBar( + state.value / state.estimate, + Math.max(4, depth > 0 ? 12 : 25), + ), + ) + + ` [${state.value}/${state.estimate}] ` + state.text; + } + const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; + return (depth === 0 ? spinner[frame] + " " : "") + + (state.value > 0 ? `[${state.value}] ` : "") + state.text; +} + +function renderChildren(state: State, now: number, depth: boolean[]) { + let maxHeight = 25; // TODO: + let truncated = 0; + let out = ""; + const { children } = state; + if (children.length === 0) return ""; + for (let i = 0, { length } = children; i < length; i += 1) { + const child = children[i]; + if (child.passive && child.children.length === 0) continue; + let item = ""; + const left = depth.map((x) => x ? box.line : " ").join(""); + item += left + (i === length - 1 && !truncated ? box.langle : box.tee); + item += renderMainLine(child, now, 1 + depth.length) + "\n"; + item += renderChildren(child, now, depth.concat(i < length - 1)); + const h = string.countNewlines(item); + if (h > maxHeight) { + truncated += 1; + continue; + } + const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; + for (const line of logLines) { + item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + + " " + line + "\n"; + } + maxHeight -= h + logLines.length; + out += item; + } + if (truncated) { + out += depth.map((x) => x ? box.line : " ").join("") + box.langle; + out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); + } + return out; +} + +/** + * This function is derived from an old program I wrote back in 2020 called `f` + * which did ffmpeg handling. It is probably one of the coolest progress bars + * ever imagined. + */ +export function getUnicodeBar(progress: number, width: number) { + if (progress >= 1) return fullBar.repeat(width); + if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); + + const wholeWidth = Math.floor(progress * width); + const remainderWidth = (progress * width) % 1; + const partWidth = Math.floor(remainderWidth * 8); + let partChar = barChars[partWidth]; + if (width - wholeWidth - 1 < 0) partChar = ""; + + const fill = fullBar.repeat(wholeWidth); + const empty = " ".repeat(width - wholeWidth - 1); + + return fill + partChar + empty; +} + +function topLevelSignaler(this: State, signal: Signal) { + if (signal === "draw") { + rerender?.(); + } +} + +import * as ts from "../ts.ts"; +import * as log from "../term.ts"; +import * as headless from "../term/headless.ts"; +import * as ansi from "./ansi.ts"; +import * as string from "../string.ts"; diff --git a/lib/ts.ts b/lib/ts.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ced87c49f3dfd67775cd75df971710359c7744e --- /dev/null +++ b/lib/ts.ts @@ -0,0 +1,11 @@ +export type Timer = ReturnType; +export type Interval = ReturnType; + +export interface Dispose extends Disposable { + (): void; +} +export function defer(fn: () => void): Dispose { + const f = fn as Dispose; + f[Symbol.dispose] = f; + return f; +} -- 2.54.0