diff --git a/lib/jsr.json b/lib/jsr.json index 5f7de769bba4746ead3b1c34026628cf6bf79e8f..94c1a1d3e8dfc9b1a84f1afcdfb0b28d19ddfcdc 100644 --- a/lib/jsr.json +++ b/lib/jsr.json @@ -3,8 +3,11 @@ "version": "1.0.0", "license": "MIT", "exports": { - "./term": "./term.ts", - "./term/ansi": "./term/ansi.ts", - "./term/headless": "./term/headless.ts" + "./log": "./log.ts", + "./log/ansi": "./log/ansi.ts", + "./log/headless": "./log/headless.ts", + "./queue": "./queue.ts", + "./string": "./string.ts", + "./ts": "./ts.ts" } } diff --git a/lib/log.ts b/lib/log.ts new file mode 100644 index 0000000000000000000000000000000000000000..836271096624e18af157bd8da7822abb61cd98b4 --- /dev/null +++ b/lib/log.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 "./log/headless.ts"; +import * as ts from "./ts.ts"; diff --git a/lib/log/ansi.ts b/lib/log/ansi.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d89d906272d80d36d4b09a6a0dd6ea010e844be --- /dev/null +++ b/lib/log/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/log/headless.ts b/lib/log/headless.ts new file mode 100644 index 0000000000000000000000000000000000000000..ff30533c15456f31a072519d4aa6403047b81459 --- /dev/null +++ b/lib/log/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: log.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: log.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): log.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): log.Scope { + const formatted = name ? name + colon : name; + const fn = logFn.bind(null, formatted, 0) as Partial; + fn.info = fn as log.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 log.Scope; + } + + function createSubScope(this: log.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 log from "../log.ts"; diff --git a/lib/log/progress.sample.ts b/lib/log/progress.sample.ts new file mode 100644 index 0000000000000000000000000000000000000000..afd45c8d8bcdc49ecdd566005b32694ae94b2eb2 --- /dev/null +++ b/lib/log/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/log/progress.ts b/lib/log/progress.ts new file mode 100644 index 0000000000000000000000000000000000000000..349a8e5edea989e16b7145e60c9a3920fc0b5245 --- /dev/null +++ b/lib/log/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 "../log.ts"; +import * as headless from "../log/headless.ts"; +import * as ansi from "./ansi.ts"; +import * as string from "../string.ts"; diff --git a/lib/readme.md b/lib/readme.md index 886dd37e7eb289bfce107b33e974f6a10d5ab626..f5bb34035f010cc333f79fd303c7da85e2ca7565 100644 --- a/lib/readme.md +++ b/lib/readme.md @@ -19,10 +19,10 @@ the public api is simply all of the code. an overview: - `string` -- `term` - logging, terminal, interactive widgets -- `term/ansi` - cursor and color constants -- `term/headless` - use `term` and `term/progress` without a pty -- `term/progress` - progress bar, spinner, and tree +- `log` - logging, terminal i/o, interactive widgets +- `log/ansi` - cursor and color constants +- `log/headless` - use `log` and `log/progress` without a tty +- `log/progress` - progress bar, spinner, and tree - `ts` typescript types and general purpose code these libraries are published as is, **without versioning or maintainance diff --git a/lib/term.ts b/lib/term.ts deleted file mode 100644 index 3a9a6c46150ef82d8d0ffd5ba651a9b84be4b9d3..0000000000000000000000000000000000000000 --- a/lib/term.ts +++ /dev/null @@ -1,102 +0,0 @@ -/** 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 deleted file mode 100644 index 9d89d906272d80d36d4b09a6a0dd6ea010e844be..0000000000000000000000000000000000000000 --- a/lib/term/ansi.ts +++ /dev/null @@ -1,94 +0,0 @@ -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 deleted file mode 100644 index 2746f2fa0e106d00e52dd455440687edddf8ec4b..0000000000000000000000000000000000000000 --- a/lib/term/headless.ts +++ /dev/null @@ -1,313 +0,0 @@ -// 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 deleted file mode 100644 index afd45c8d8bcdc49ecdd566005b32694ae94b2eb2..0000000000000000000000000000000000000000 --- a/lib/term/progress.sample.ts +++ /dev/null @@ -1,71 +0,0 @@ -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 deleted file mode 100644 index 8482ef81440058ab1107781137ca18f86c268431..0000000000000000000000000000000000000000 --- a/lib/term/progress.ts +++ /dev/null @@ -1,346 +0,0 @@ -// 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/run.js b/run.js index f74beabb6fa79f25bc3b2cb51ab9ca6914a5687a..8a5f55ec4cdd078395ce6bb2786832489241c71f 100644 --- a/run.js +++ b/run.js @@ -41,17 +41,17 @@ try { // Init hooks const hot = await import("./framework/hot.ts"); -const console = hot.load("@paperclover/console"); -globalThis.console["log"] = console.info; -globalThis.console.info = console.info; -globalThis.console.warn = console.warn; -globalThis.console.error = console.error; -globalThis.console.debug = console.scoped("dbg"); +const log = hot.load("./lib/log.ts"); +console.info = log.info; +console.warn = log.warn; +console.error = log.error; +console.debug = log.scoped("debug"); +console.log = console.debug; // Load with hooks if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { if (process.argv.length == 2) { - console.error("usage: node run