| author | |
| committer | |
| log | 293d92bc8ba73bbf125387b927096b2c67a61081 |
| tree | 41961b174b861e08976d79c2bda1d63e8bee5a18 |
| parent | 30b55e0c92fb15df546351106865c3b01fe1e912 |
| signature | Commit is signed but in an unrecognized format. |
15 files changed, 951 insertions(+), 948 deletions(-)
lib/jsr.json+6-3| ... | @@ -3,8 +3,11 @@ | ... | @@ -3,8 +3,11 @@ |
| 3 | "version": "1.0.0", | 3 | "version": "1.0.0", |
| 4 | "license": "MIT", | 4 | "license": "MIT", |
| 5 | "exports": { | 5 | "exports": { |
| 6 | "./term": "./term.ts", | 6 | "./log": "./log.ts", |
| 7 | "./term/ansi": "./term/ansi.ts", | 7 | "./log/ansi": "./log/ansi.ts", |
| 8 | "./term/headless": "./term/headless.ts" | 8 | "./log/headless": "./log/headless.ts", |
| 9 | "./queue": "./queue.ts", | ||
| 10 | "./string": "./string.ts", | ||
| 11 | "./ts": "./ts.ts" | ||
| 9 | } | 12 | } |
| 10 | } | 13 | } |
lib/log.ts created+102| ... | @@ -0,0 +1,102 @@ | ||
| 1 | /** informational */ | ||
| 2 | export function info(...args: unknown[]) { | ||
| 3 | globalLog(...args); | ||
| 4 | } | ||
| 5 | /** advisory */ | ||
| 6 | export function warn(...args: unknown[]) { | ||
| 7 | globalLog.warn(...args); | ||
| 8 | } | ||
| 9 | /** failure */ | ||
| 10 | export function error(...args: unknown[]) { | ||
| 11 | globalLog.error(...args); | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface Scope { | ||
| 15 | /** default call signature is informational scope */ | ||
| 16 | (...args: unknown[]): void; | ||
| 17 | /** informational */ | ||
| 18 | info(...args: unknown[]): void; | ||
| 19 | /** advisory */ | ||
| 20 | warn(...args: unknown[]): void; | ||
| 21 | /** failure */ | ||
| 22 | error(...args: unknown[]): void; | ||
| 23 | |||
| 24 | /** prefix */ | ||
| 25 | name: string; | ||
| 26 | scoped(name: string): Scope; | ||
| 27 | } | ||
| 28 | |||
| 29 | /** custom scopes are colored based on the name */ | ||
| 30 | export function scoped(name: string): Scope { | ||
| 31 | return globalLog.scoped(name); | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * no built-in prefix or formatting. ensures the text does not interweave. | ||
| 36 | * data will be flushed in the next frame or when drawing is unlocked. | ||
| 37 | */ | ||
| 38 | export function writeLine(text: string) { | ||
| 39 | globalWidgetHost.writeLine(text); | ||
| 40 | } | ||
| 41 | |||
| 42 | /** | ||
| 43 | * while locked, no widgets will draw. prefer `writeLine`. | ||
| 44 | * this lock is not exclusive. | ||
| 45 | */ | ||
| 46 | export function getDrawLock(): ts.Dispose { | ||
| 47 | return globalWidgetHost.getDrawLock(); | ||
| 48 | } | ||
| 49 | |||
| 50 | /** | ||
| 51 | * a widget is an interactive display that persists at the end | ||
| 52 | * of the log. this can be used to implement status bars, progress | ||
| 53 | * indicators, and other human i/o. only 'format' is required. | ||
| 54 | */ | ||
| 55 | export function startWidget(widget: Widget): ts.Dispose { | ||
| 56 | return globalWidgetHost.startWidget(widget); | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface Widget { | ||
| 60 | /** | ||
| 61 | * return the widget's text. return null to detach the widget. | ||
| 62 | * may get called more often than the specified `fps`. | ||
| 63 | * supports color codes but not ansi cursor movements. | ||
| 64 | */ | ||
| 65 | format( | ||
| 66 | now: ReturnType<typeof performance.now>, | ||
| 67 | focus: boolean, | ||
| 68 | ): string | null; | ||
| 69 | /** 'null' to never update (use 'onChange'). defaults to 12 fps */ | ||
| 70 | fps?: number | null; | ||
| 71 | /** Subscribe to manual widget updates. Call `rerender` when needed. */ | ||
| 72 | onChange?(rerender: () => void): () => void; | ||
| 73 | // /** | ||
| 74 | // * listen for keyboard events. | ||
| 75 | // */ | ||
| 76 | // onKey?(key: string): void; | ||
| 77 | } | ||
| 78 | |||
| 79 | const globalWidgetHost = /* @__PURE__ */ (() => { | ||
| 80 | const widget = headless.widgetHost({ | ||
| 81 | // TODO: consider synchronization and make these async | ||
| 82 | writeOutput: (string) => process.stdout.write(string), | ||
| 83 | writeInteractive: (string) => process.stderr.write(string), | ||
| 84 | now: () => performance.now(), | ||
| 85 | wait: (ms, cb) => { | ||
| 86 | const id = setTimeout(cb, ms); | ||
| 87 | return () => clearTimeout(id); | ||
| 88 | }, | ||
| 89 | getSize: () => process.stderr, | ||
| 90 | }); | ||
| 91 | process.addListener("beforeExit", () => widget.cancel()); | ||
| 92 | process.addListener("exit", () => widget.cancel()); | ||
| 93 | return widget; | ||
| 94 | })(); | ||
| 95 | const globalLog = /* @__PURE__ */ headless.logger({ | ||
| 96 | log: globalWidgetHost, | ||
| 97 | colors: process.stderr.isTTY, | ||
| 98 | }); | ||
| 99 | |||
| 100 | import process from "node:process"; | ||
| 101 | import * as headless from "./log/headless.ts"; | ||
| 102 | import * as ts from "./ts.ts"; | ||
lib/log/ansi.ts created+94| ... | @@ -0,0 +1,94 @@ | ||
| 1 | export const reset: string = `\x1b[0m`; | ||
| 2 | |||
| 3 | export const fgReset: string = `\x1b[39m`; | ||
| 4 | export const fgBlack: string = `\x1b[30m`; | ||
| 5 | export const fgRed: string = `\x1b[31m`; | ||
| 6 | export const fgGreen: string = `\x1b[32m`; | ||
| 7 | export const fgYellow: string = `\x1b[33m`; | ||
| 8 | export const fgBlue: string = `\x1b[34m`; | ||
| 9 | export const fgPurple: string = `\x1b[35m`; | ||
| 10 | export const fgCyan: string = `\x1b[36m`; | ||
| 11 | export const fgWhite: string = `\x1b[37m`; | ||
| 12 | export const fgBrightBlack: string = `\x1b[90m`; | ||
| 13 | export const fgBrightRed: string = `\x1b[91m`; | ||
| 14 | export const fgBrightGreen: string = `\x1b[92m`; | ||
| 15 | export const fgBrightYellow: string = `\x1b[93m`; | ||
| 16 | export const fgBrightBlue: string = `\x1b[94m`; | ||
| 17 | export const fgBrightPurple: string = `\x1b[95m`; | ||
| 18 | export const fgBrightCyan: string = `\x1b[96m`; | ||
| 19 | export const fgBrightWhite: string = `\x1b[97m`; | ||
| 20 | export function fg256(n: number): string { | ||
| 21 | return `\x1b[38;5;${n}m`; | ||
| 22 | } | ||
| 23 | export function fgRgb(r: number, g: number, b: number): string { | ||
| 24 | return `\x1b[38;2;${r};${g};${b}m`; | ||
| 25 | } | ||
| 26 | |||
| 27 | export const bgReset: string = `\x1b[49m`; | ||
| 28 | export const bgBlack: string = `\x1b[40m`; | ||
| 29 | export const bgRed: string = `\x1b[41m`; | ||
| 30 | export const bgGreen: string = `\x1b[42m`; | ||
| 31 | export const bgYellow: string = `\x1b[43m`; | ||
| 32 | export const bgBlue: string = `\x1b[44m`; | ||
| 33 | export const bgPurple: string = `\x1b[45m`; | ||
| 34 | export const bgCyan: string = `\x1b[46m`; | ||
| 35 | export const bgWhite: string = `\x1b[47m`; | ||
| 36 | export const bgBrightBlack: string = `\x1b[100m`; | ||
| 37 | export const bgBrightRed: string = `\x1b[101m`; | ||
| 38 | export const bgBrightGreen: string = `\x1b[102m`; | ||
| 39 | export const bgBrightYellow: string = `\x1b[103m`; | ||
| 40 | export const bgBrightBlue: string = `\x1b[104m`; | ||
| 41 | export const bgBrightPurple: string = `\x1b[105m`; | ||
| 42 | export const bgBrightCyan: string = `\x1b[106m`; | ||
| 43 | export const bgBrightWhite: string = `\x1b[107m`; | ||
| 44 | export function bg256(n: number): string { | ||
| 45 | return `\x1b[48;5;${n}m`; | ||
| 46 | } | ||
| 47 | export function bgRgb(r: number, g: number, b: number): string { | ||
| 48 | return `\x1b[48;2;${r};${g};${b}m`; | ||
| 49 | } | ||
| 50 | |||
| 51 | // cursor management | ||
| 52 | export function cursorUp(n: number) { | ||
| 53 | return n ? `\x1b[${n}A` : ""; | ||
| 54 | } | ||
| 55 | export const startOfLine: string = "\r"; | ||
| 56 | export const startOfNextLine: string = "\n"; | ||
| 57 | |||
| 58 | // erasure | ||
| 59 | export const clearToEndOfLine: string = "\x1b[K"; | ||
| 60 | export const clearToStartOfLine: string = "\x1b[1K"; | ||
| 61 | export const clearFullLine: string = "\x1b[2K"; | ||
| 62 | |||
| 63 | // Synchronized Output | ||
| 64 | // https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 | ||
| 65 | export const syncStart: string = "\x1b[?2026h"; | ||
| 66 | export const syncEnd: string = "\x1b[?2026l"; | ||
| 67 | |||
| 68 | export function style(code: string, text: string): string { | ||
| 69 | code = mergeStyles(code); | ||
| 70 | const fg = /[\[;][39]/.test(code); | ||
| 71 | const bg = /[\[;](?:4|10)/.test(code); | ||
| 72 | return code + text + | ||
| 73 | mergeStyles((fg ? fgReset : "") + (bg ? bgReset : "")); | ||
| 74 | } | ||
| 75 | |||
| 76 | /** | ||
| 77 | * Given a string of multiple style ansi codes, combines all of them. | ||
| 78 | * Must be used on a string of only ansi style codes. | ||
| 79 | */ | ||
| 80 | export function mergeStyles(code: string) { | ||
| 81 | let styles = []; | ||
| 82 | while (code.length) { | ||
| 83 | if (!code.startsWith("\x1b[")) { | ||
| 84 | throw new Error("invalid style string: " + JSON.stringify(code)); | ||
| 85 | } | ||
| 86 | const end = code.indexOf("m"); | ||
| 87 | if (end === -1) { | ||
| 88 | throw new Error("invalid style string: " + JSON.stringify(code)); | ||
| 89 | } | ||
| 90 | styles.push(code.slice(2, end)); | ||
| 91 | code = code.slice(end + 1); | ||
| 92 | } | ||
| 93 | return `\x1b[${styles.join(";")}m`; | ||
| 94 | } | ||
lib/log/headless.ts created+313| ... | @@ -0,0 +1,313 @@ | ||
| 1 | // This file is under heavy construction. | ||
| 2 | // | ||
| 3 | // Rendering for 'lib/term' is done by specifying all system APIs up front in | ||
| 4 | // an interface and creating an instance of the widget host. The main file | ||
| 5 | // fufills this interface with `node:process` and `globalThis`. | ||
| 6 | // | ||
| 7 | // Some notes on widget rendering: | ||
| 8 | // - Never flush immediately (exception for `getDrawLock`), always do it next tick. | ||
| 9 | // - Maximum of one `wait` call at once. When the expected time suddenly | ||
| 10 | // shrinks, the timer is rescheduled. | ||
| 11 | // - When redrawing widget lines, three tricks are done to reduce flickering: | ||
| 12 | // 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd) | ||
| 13 | // 2. A simple prefix-based diffing algorithm for skipping unchanged text | ||
| 14 | // 3. Avoid clearing a line before redrawing it. | ||
| 15 | // Points 2 and 3 are used for terminals that are slow or have | ||
| 16 | // | ||
| 17 | |||
| 18 | export interface WidgetHostEnv { | ||
| 19 | writeInteractive(text: string): void; | ||
| 20 | writeOutput(text: string): void; | ||
| 21 | now(): ReturnType<typeof performance.now>; | ||
| 22 | /** 0ms indicates "one frame" */ | ||
| 23 | wait(ms: number, cb: () => void): () => void; | ||
| 24 | getSize(): { columns: number; rows: number }; | ||
| 25 | // onInput?(write: (bytes: Uint8Array | string) => void): () => void; | ||
| 26 | } | ||
| 27 | |||
| 28 | /** Not intended to be implemented manually. See `widgetHost` */ | ||
| 29 | export interface WidgetHost { | ||
| 30 | /** | ||
| 31 | * no built-in prefix or formatting. ensures the text does not interweave. | ||
| 32 | * data will be flushed in the next frame or when drawing is unlocked. | ||
| 33 | */ | ||
| 34 | writeLine(text: string): void; | ||
| 35 | /** | ||
| 36 | * while locked, no widgets will draw. prefer `writeLine`. | ||
| 37 | * this lock is not exclusive. | ||
| 38 | */ | ||
| 39 | getDrawLock(): ts.Dispose; | ||
| 40 | /** | ||
| 41 | * a widget is an interactive display that persists at the end | ||
| 42 | * of the log. this can be used to implement status bars, progress | ||
| 43 | * indicators, and other human i/o. only `format` is required. | ||
| 44 | */ | ||
| 45 | startWidget(widget: log.Widget): ts.Dispose; | ||
| 46 | /** stop all widgets and remove all timers. */ | ||
| 47 | cancel(): void; | ||
| 48 | } | ||
| 49 | |||
| 50 | interface WidgetState { | ||
| 51 | frameTime: number; | ||
| 52 | next: number; | ||
| 53 | unsub: (() => void) | null; | ||
| 54 | } | ||
| 55 | |||
| 56 | /** Implement a widget host by providing an environment. */ | ||
| 57 | export function widgetHost(env: WidgetHostEnv): WidgetHost { | ||
| 58 | const { writeOutput, writeInteractive, now, wait, getSize } = env; | ||
| 59 | type Cancel = ReturnType<typeof wait>; | ||
| 60 | |||
| 61 | let timer: Cancel | null = null; | ||
| 62 | |||
| 63 | let locks = 0; | ||
| 64 | let redrawTime = 0; | ||
| 65 | let lastFlush = 0; | ||
| 66 | let buffer = ""; | ||
| 67 | const widgets: log.Widget[] = []; | ||
| 68 | const internals: WidgetState[] = []; | ||
| 69 | let lines: string[] = []; | ||
| 70 | |||
| 71 | function redrawCallback() { | ||
| 72 | timer = null; | ||
| 73 | redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround | ||
| 74 | |||
| 75 | if (!lines.length && !widgets.length) { | ||
| 76 | buffer && writeOutput(buffer); | ||
| 77 | buffer = ""; | ||
| 78 | return; | ||
| 79 | } | ||
| 80 | |||
| 81 | const { columns, rows } = getSize(); | ||
| 82 | let newWidgetLines: string[] = []; | ||
| 83 | let next = Infinity; | ||
| 84 | if (widgets[0]) { | ||
| 85 | for (let w = 0, { length } = widgets; w < length; w += 1) { | ||
| 86 | const outText = widgets[w].format(lastFlush, false); | ||
| 87 | if (!outText) { | ||
| 88 | widgets.splice(w, 1); | ||
| 89 | internals.splice(w, 1)[0].unsub?.(); | ||
| 90 | w -= 1; | ||
| 91 | length -= 1; | ||
| 92 | continue; | ||
| 93 | } | ||
| 94 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); | ||
| 95 | if (rowsLeft === 1) break; | ||
| 96 | const lines = outText.split("\n").slice(0, rowsLeft); | ||
| 97 | newWidgetLines.push( | ||
| 98 | ...lines.map((line) => string.trimToWidth(line, columns - 1)), | ||
| 99 | ); | ||
| 100 | |||
| 101 | next = Math.min(next, internals[w].frameTime); | ||
| 102 | } | ||
| 103 | |||
| 104 | newWidgetLines = newWidgetLines.slice(0, rows - 1); | ||
| 105 | } | ||
| 106 | |||
| 107 | if (next < Infinity) redrawSoon(next); | ||
| 108 | |||
| 109 | if (!newWidgetLines[0]) { | ||
| 110 | if (lines.length) { | ||
| 111 | let clearLinesTop = Math.min( | ||
| 112 | lines.length, | ||
| 113 | string.countNewlines(buffer), | ||
| 114 | ); | ||
| 115 | writeInteractive( | ||
| 116 | ansi.startOfLine + | ||
| 117 | // skip up the shared widget space | ||
| 118 | ansi.cursorUp(lines.length) + | ||
| 119 | // clear the lines to contain `buffer` | ||
| 120 | (ansi.clearFullLine + ansi.startOfNextLine) | ||
| 121 | .repeat(clearLinesTop) + | ||
| 122 | ansi.cursorUp(clearLinesTop), | ||
| 123 | ); | ||
| 124 | lines = []; | ||
| 125 | } | ||
| 126 | buffer && writeOutput(buffer); | ||
| 127 | buffer = ""; | ||
| 128 | return; | ||
| 129 | } | ||
| 130 | |||
| 131 | if (buffer) { | ||
| 132 | // do not perform diffing since the entire screen is moving down | ||
| 133 | // TODO: should i handle wrapping? | ||
| 134 | let clearLinesTop = Math.min( | ||
| 135 | lines.length, | ||
| 136 | string.countNewlines(buffer), | ||
| 137 | ); | ||
| 138 | let clearLinesBottom = Math.min( | ||
| 139 | lines.length - clearLinesTop, | ||
| 140 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | ||
| 141 | ); | ||
| 142 | writeInteractive( | ||
| 143 | ansi.syncStart + ansi.startOfLine + | ||
| 144 | // clear the bottom lines | ||
| 145 | (clearLinesBottom | ||
| 146 | ? ansi.cursorUp(1) + | ||
| 147 | (ansi.clearToEndOfLine + ansi.cursorUp(1)) | ||
| 148 | .repeat(clearLinesBottom) | ||
| 149 | : "") + | ||
| 150 | // skip up the shared widget space | ||
| 151 | ansi.cursorUp(lines.length - clearLinesBottom) + | ||
| 152 | // clear the lines to contain `buffer` | ||
| 153 | (ansi.clearFullLine + ansi.startOfNextLine) | ||
| 154 | .repeat(clearLinesTop) + | ||
| 155 | ansi.cursorUp(clearLinesTop), | ||
| 156 | ); | ||
| 157 | // then write output lines on standard out | ||
| 158 | writeOutput(buffer); | ||
| 159 | writeInteractive( | ||
| 160 | // the widget text | ||
| 161 | newWidgetLines.join("\n") + "\n" + | ||
| 162 | ansi.syncEnd, | ||
| 163 | ); | ||
| 164 | } else { | ||
| 165 | const clearLinesBottom = Math.min( | ||
| 166 | lines.length, | ||
| 167 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | ||
| 168 | ); | ||
| 169 | writeInteractive( | ||
| 170 | ansi.syncStart + ansi.startOfLine + | ||
| 171 | // clear the bottom lines | ||
| 172 | (clearLinesBottom | ||
| 173 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) | ||
| 174 | .repeat(clearLinesBottom) | ||
| 175 | : "") + | ||
| 176 | // skip up the widget space | ||
| 177 | ansi.cursorUp(lines.length - clearLinesBottom) + | ||
| 178 | // the widget text | ||
| 179 | newWidgetLines.map((x) => | ||
| 180 | (x.includes("\x1b") && !x.endsWith(ansi.reset) | ||
| 181 | ? x + ansi.reset | ||
| 182 | : x) + | ||
| 183 | ansi.clearToEndOfLine + "\n" | ||
| 184 | ).join("") + | ||
| 185 | ansi.syncEnd, | ||
| 186 | ); | ||
| 187 | } | ||
| 188 | lines = newWidgetLines; | ||
| 189 | |||
| 190 | buffer = ""; | ||
| 191 | } | ||
| 192 | |||
| 193 | function redrawSoon(ms: number) { | ||
| 194 | if (locks > 0 || (ms === 0 && timer)) return; | ||
| 195 | const newRedrawTime = now() + ms; | ||
| 196 | if (timer) { | ||
| 197 | if (redrawTime < newRedrawTime) return; | ||
| 198 | timer(); // cancel previous | ||
| 199 | timer = null; | ||
| 200 | } | ||
| 201 | redrawTime = newRedrawTime + 1; | ||
| 202 | timer = wait(ms, redrawCallback); | ||
| 203 | } | ||
| 204 | |||
| 205 | function flushAndClear() { | ||
| 206 | timer?.(); | ||
| 207 | timer = null; | ||
| 208 | if (lines.length > 0) { | ||
| 209 | // clear widgets | ||
| 210 | } | ||
| 211 | if (buffer.length > 0) writeOutput(buffer), buffer = ""; | ||
| 212 | } | ||
| 213 | |||
| 214 | return { | ||
| 215 | writeLine(chunk) { | ||
| 216 | buffer += chunk + "\n"; | ||
| 217 | redrawSoon(0); | ||
| 218 | }, | ||
| 219 | getDrawLock() { | ||
| 220 | if (locks === 0) flushAndClear(); | ||
| 221 | locks += 1; | ||
| 222 | return ts.defer(() => { | ||
| 223 | locks -= 1; | ||
| 224 | if (locks === 0) { | ||
| 225 | if (buffer.length > 0) writeOutput(buffer), buffer = ""; | ||
| 226 | // TODO: reinit widget | ||
| 227 | } | ||
| 228 | }); | ||
| 229 | }, | ||
| 230 | startWidget(w) { | ||
| 231 | if (!widgets.includes(w)) { | ||
| 232 | const state: WidgetState = { | ||
| 233 | next: 0, | ||
| 234 | unsub: null, | ||
| 235 | frameTime: 1000 / (w.fps ?? 0), | ||
| 236 | }; | ||
| 237 | widgets.push(w); | ||
| 238 | internals.push(state); | ||
| 239 | state.unsub = w.onChange?.(() => { | ||
| 240 | state.next = 0; | ||
| 241 | redrawSoon(0); | ||
| 242 | }) ?? null; | ||
| 243 | redrawSoon(0); | ||
| 244 | } | ||
| 245 | return ts.defer(() => { | ||
| 246 | const i = widgets.indexOf(w); | ||
| 247 | widgets.splice(i, 1); | ||
| 248 | internals.splice(i, 1)[0].unsub?.(); | ||
| 249 | redrawSoon(0); | ||
| 250 | }); | ||
| 251 | }, | ||
| 252 | cancel() { | ||
| 253 | flushAndClear(); | ||
| 254 | }, | ||
| 255 | }; | ||
| 256 | } | ||
| 257 | |||
| 258 | export interface LoggerOptions { | ||
| 259 | /** Provide either a line writer or a log writer. */ | ||
| 260 | log: { | ||
| 261 | writeLine: WidgetHost["writeLine"]; | ||
| 262 | } | { | ||
| 263 | writeLog: (prefix: string, level: Level, ...args: unknown[]) => void; | ||
| 264 | }; | ||
| 265 | colors: boolean; | ||
| 266 | } | ||
| 267 | |||
| 268 | export function logger(env: LoggerOptions): log.Scope { | ||
| 269 | const { colors, log } = env; | ||
| 270 | const levels = colors | ||
| 271 | ? [ | ||
| 272 | ansi.style(ansi.fgBlue, "info"), | ||
| 273 | ansi.style(ansi.fgYellow, "warn"), | ||
| 274 | ansi.style(ansi.fgRed, "error"), | ||
| 275 | ] as const | ||
| 276 | : ["info", "warn", "error"] as const; | ||
| 277 | const colon = colors ? ansi.style(ansi.fgBrightBlack, ":") + " " : ": "; | ||
| 278 | const logFn = "writeLog" in log | ||
| 279 | ? log.writeLog | ||
| 280 | : ((prefix: string, level: Level, ...args: unknown[]) => { | ||
| 281 | log.writeLine( | ||
| 282 | (prefix | ||
| 283 | ? prefix + (level > 0 ? levels[level] + colon : "") | ||
| 284 | : (levels[level] + colon)) + util.format(...args), | ||
| 285 | ); | ||
| 286 | }); | ||
| 287 | |||
| 288 | function scoped(name: string): log.Scope { | ||
| 289 | const formatted = name ? name + colon : name; | ||
| 290 | const fn = logFn.bind(null, formatted, 0) as Partial<log.Scope>; | ||
| 291 | fn.info = fn as log.Scope["info"]; | ||
| 292 | fn.warn = logFn.bind(null, formatted, 1); | ||
| 293 | fn.error = logFn.bind(null, formatted, 2); | ||
| 294 | fn.scoped = createSubScope; | ||
| 295 | Object.defineProperty(fn, "name", { value: name }); | ||
| 296 | return fn as log.Scope; | ||
| 297 | } | ||
| 298 | |||
| 299 | function createSubScope(this: log.Scope, name: string) { | ||
| 300 | const { name: parent } = this; | ||
| 301 | return scoped(parent ? parent + "/" + name : name); | ||
| 302 | } | ||
| 303 | |||
| 304 | return scoped(""); | ||
| 305 | } | ||
| 306 | |||
| 307 | type Level = 0 | 1 | 2; | ||
| 308 | |||
| 309 | import * as ansi from "./ansi.ts"; | ||
| 310 | import * as string from "../string.ts"; | ||
| 311 | import * as ts from "../ts.ts"; | ||
| 312 | import * as util from "node:util"; | ||
| 313 | import type * as log from "../log.ts"; | ||
lib/log/progress.sample.ts created+71| ... | @@ -0,0 +1,71 @@ | ||
| 1 | const root = progress.start("progress node", { estimate: 30 }); | ||
| 2 | for (let i = 0; i < 10; i += 1) { | ||
| 3 | { | ||
| 4 | const subNodes = Array.from( | ||
| 5 | { length: 4 }, | ||
| 6 | (_) => root.start("subtask " + random()), | ||
| 7 | ); | ||
| 8 | await delay(); | ||
| 9 | subNodes[0]!.end(); | ||
| 10 | const n = subNodes[1]!.start("meowing", { estimate: 7 }); | ||
| 11 | const m = subNodes[1]!.start("purring", { estimate: 7 }); | ||
| 12 | let w: progress.Node | null = null; | ||
| 13 | let x: progress.Node | null = null; | ||
| 14 | let y: progress.Node | null = null; | ||
| 15 | let z: progress.Node | null = null; | ||
| 16 | for (let i = 0; i < 14; i += 1) { | ||
| 17 | if (Math.random() > 0.5 && n.value < n.estimate) { | ||
| 18 | n.inc(); | ||
| 19 | } else { | ||
| 20 | m.inc(); | ||
| 21 | } | ||
| 22 | await short(); | ||
| 23 | if (i === 5) subNodes[3]!.end(); | ||
| 24 | if (i === 3) { | ||
| 25 | x = subNodes[2]!.start("other task"); | ||
| 26 | y = x.start("deeply nested"); | ||
| 27 | z = y.start("job"); | ||
| 28 | } | ||
| 29 | if (i == 8) z?.end(); | ||
| 30 | if (i === 6) w = y!.start("magic"); | ||
| 31 | if (i == 12) y!.end(); | ||
| 32 | if (i === 10) subNodes[3]!.end(); | ||
| 33 | } | ||
| 34 | await delay(); | ||
| 35 | n.inc(); | ||
| 36 | await delay(); | ||
| 37 | n.end(); | ||
| 38 | root.inc(); | ||
| 39 | subNodes.forEach((x) => x.end()); | ||
| 40 | } | ||
| 41 | { | ||
| 42 | const subNodes = Array.from( | ||
| 43 | { length: 8 }, | ||
| 44 | (_) => root.start("subtask with count " + random()), | ||
| 45 | ); | ||
| 46 | subNodes.forEach((x) => x.value = 1); | ||
| 47 | await delay(); | ||
| 48 | subNodes[0]!.end(); | ||
| 49 | for (let i = 0; i < 80; i += 1) { | ||
| 50 | const node = subNodes[Math.floor(Math.random() * 8)]!; | ||
| 51 | node.inc(); | ||
| 52 | if (node.value > 10) node.end(); | ||
| 53 | await short(); | ||
| 54 | } | ||
| 55 | await delay(); | ||
| 56 | subNodes.forEach((x) => x.end()); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | async function delay() { | ||
| 61 | await timers.setTimeout(Math.random() * 100 + 200); | ||
| 62 | } | ||
| 63 | async function short() { | ||
| 64 | await timers.setTimeout(Math.random() * 50 + 50); | ||
| 65 | } | ||
| 66 | function random() { | ||
| 67 | return Math.floor(Math.random() * 9999999).toString(32); | ||
| 68 | } | ||
| 69 | |||
| 70 | import * as progress from "./progress.ts"; | ||
| 71 | import * as timers from "node:timers/promises"; | ||
lib/log/progress.ts created+346| ... | @@ -0,0 +1,346 @@ | ||
| 1 | // This file is under heavy construction. | ||
| 2 | // | ||
| 3 | // A progress tree based on my past uses with clover console v3 and the Zig | ||
| 4 | // Progress API. Pass `Progress` to functions which should track progress, | ||
| 5 | // calling `.start()` to create child items. Or, multiple top-level progress | ||
| 6 | // bars can be created with the singleton's `start()` function. | ||
| 7 | import * as self from "./progress.ts"; | ||
| 8 | self satisfies Ref; // prove you can use the namespace import as a `Progress` | ||
| 9 | |||
| 10 | /** | ||
| 11 | * creates a new trackable unit of work as a child of this one. | ||
| 12 | * when given an estimate, a progress bar is rendered. | ||
| 13 | */ | ||
| 14 | export function start(text: string, opts?: StartOptions): Node { | ||
| 15 | const [state, node] = newNode(text, opts); | ||
| 16 | globalState.push(state); | ||
| 17 | state.signal = topLevelSignaler; | ||
| 18 | if (rerender) rerender(); | ||
| 19 | else log.startWidget(globalWidget); | ||
| 20 | return node; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface Ref { | ||
| 24 | /** | ||
| 25 | * creates a new trackable unit of work as a child of this one. | ||
| 26 | * when given an estimate, a progress bar is rendered. | ||
| 27 | */ | ||
| 28 | start(text: string, opts?: StartOptions): Node; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface Node extends Ref { | ||
| 32 | /** reactive */ | ||
| 33 | text: string; | ||
| 34 | /** reactive */ | ||
| 35 | value: number; | ||
| 36 | /** reactive */ | ||
| 37 | estimate: number; | ||
| 38 | /** | ||
| 39 | * reactive, default `true`. `false` will hides a bar + printing estimate, | ||
| 40 | * but preseving auto-end behavior of `inc` | ||
| 41 | */ | ||
| 42 | showEstimate: boolean; | ||
| 43 | /** | ||
| 44 | * reactive, default `false`. when true, this progress item is hidden if | ||
| 45 | * there are no children. | ||
| 46 | */ | ||
| 47 | passive: boolean; | ||
| 48 | /** reactive. null means decide based on nested depth + terminal height */ | ||
| 49 | maxHeight: null | number; | ||
| 50 | /** reactive. specify sorting for children */ | ||
| 51 | sortChildren: ((a: SortNode, b: SortNode) => number) | null; | ||
| 52 | |||
| 53 | /** defaults to increasing by 1. hitting estimate calls end */ | ||
| 54 | inc(value?: number): void; | ||
| 55 | |||
| 56 | /** A scoped logger set to output inline to this item. */ | ||
| 57 | log: log.Scope; | ||
| 58 | |||
| 59 | /** end this Node or RootNode. ending collapses children */ | ||
| 60 | end(): void; | ||
| 61 | // /** signal that there will be a failure without ending the node */ | ||
| 62 | // pendingFail(): void; | ||
| 63 | // /** end this node with a failure */ | ||
| 64 | // endFail(): void; | ||
| 65 | |||
| 66 | [Symbol.dispose](): void; | ||
| 67 | } | ||
| 68 | |||
| 69 | export interface StartOptions { | ||
| 70 | estimate?: number | undefined | null; | ||
| 71 | } | ||
| 72 | |||
| 73 | /** a node that satisfies the api */ | ||
| 74 | const nullNode: Node = | ||
| 75 | (function start(text: string, { estimate }: StartOptions = {}): Node { | ||
| 76 | return { | ||
| 77 | start, | ||
| 78 | text, | ||
| 79 | value: 0, | ||
| 80 | estimate: estimate ?? 0, | ||
| 81 | showEstimate: true, | ||
| 82 | passive: false, | ||
| 83 | maxHeight: null, | ||
| 84 | sortChildren: null, | ||
| 85 | inc() { | ||
| 86 | this.value += 1; | ||
| 87 | }, | ||
| 88 | log: log.scoped(""), | ||
| 89 | end() {}, | ||
| 90 | [Symbol.dispose]() {}, | ||
| 91 | }; | ||
| 92 | })("root"); | ||
| 93 | |||
| 94 | export { nullNode as null }; | ||
| 95 | |||
| 96 | type SortNode = | ||
| 97 | & Pick< | ||
| 98 | Node, | ||
| 99 | | "text" | ||
| 100 | | "value" | ||
| 101 | | "estimate" | ||
| 102 | | "passive" | ||
| 103 | | "showEstimate" | ||
| 104 | | "sortChildren" | ||
| 105 | > | ||
| 106 | & { children: SortNode[] }; | ||
| 107 | |||
| 108 | type Signal = "draw" | "end" | "endFail" | "pendingFail"; | ||
| 109 | interface State extends SortNode { | ||
| 110 | logs: string[]; | ||
| 111 | children: State[]; | ||
| 112 | parent: State | null; | ||
| 113 | signal(signal: Signal): void; | ||
| 114 | } | ||
| 115 | |||
| 116 | function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { | ||
| 117 | const state: State = { | ||
| 118 | text, | ||
| 119 | value: 0, | ||
| 120 | estimate: estimate ?? 0, | ||
| 121 | showEstimate: true, | ||
| 122 | passive: false, | ||
| 123 | logs: [], | ||
| 124 | children: [], | ||
| 125 | parent: null, | ||
| 126 | sortChildren: null, | ||
| 127 | signal() { | ||
| 128 | throw new Error("no signaler"); | ||
| 129 | }, | ||
| 130 | }; | ||
| 131 | function end() { | ||
| 132 | if (!state.parent) return; | ||
| 133 | const i = state.parent.children.indexOf(state); | ||
| 134 | if (i === -1) return; | ||
| 135 | state.signal("draw"); | ||
| 136 | state.parent.children.splice(i, 1); | ||
| 137 | state.parent = null; | ||
| 138 | } | ||
| 139 | function mutate() { | ||
| 140 | const { parent } = state; | ||
| 141 | parent?.sortChildren && parent.children.sort(parent.sortChildren); | ||
| 142 | state.signal("draw"); | ||
| 143 | } | ||
| 144 | const scope = headless.logger({ | ||
| 145 | log: { | ||
| 146 | writeLine(line) { | ||
| 147 | state.logs.push(...line.split("\n")); | ||
| 148 | state.signal("draw"); | ||
| 149 | }, | ||
| 150 | }, | ||
| 151 | colors: process.stderr.isTTY, | ||
| 152 | }); | ||
| 153 | const binding: Node = { | ||
| 154 | start(text, opts) { | ||
| 155 | const [child, node] = newNode(text, opts); | ||
| 156 | state.children.push(child); | ||
| 157 | state.sortChildren && state.children.sort(state.sortChildren); | ||
| 158 | child.parent = state; | ||
| 159 | child.signal = state.signal; | ||
| 160 | state.signal("draw"); | ||
| 161 | return node; | ||
| 162 | }, | ||
| 163 | get text() { | ||
| 164 | return state.text; | ||
| 165 | }, | ||
| 166 | set text(value) { | ||
| 167 | state.text = value; | ||
| 168 | mutate(); | ||
| 169 | }, | ||
| 170 | get value() { | ||
| 171 | return state.value; | ||
| 172 | }, | ||
| 173 | set value(value) { | ||
| 174 | state.value = value; | ||
| 175 | mutate(); | ||
| 176 | }, | ||
| 177 | get estimate() { | ||
| 178 | return state.estimate; | ||
| 179 | }, | ||
| 180 | set estimate(value) { | ||
| 181 | state.estimate = value; | ||
| 182 | mutate(); | ||
| 183 | }, | ||
| 184 | get showEstimate() { | ||
| 185 | return state.showEstimate; | ||
| 186 | }, | ||
| 187 | set showEstimate(value) { | ||
| 188 | state.showEstimate = value; | ||
| 189 | mutate(); | ||
| 190 | }, | ||
| 191 | get passive() { | ||
| 192 | return state.passive; | ||
| 193 | }, | ||
| 194 | set passive(value) { | ||
| 195 | state.passive = value; | ||
| 196 | mutate(); | ||
| 197 | }, | ||
| 198 | maxHeight: null, | ||
| 199 | // get maxHeight() { | ||
| 200 | // return state.maxHeight; | ||
| 201 | // }, | ||
| 202 | // set maxHeight(value) { | ||
| 203 | // state.maxHeight = value; | ||
| 204 | // mutate(); | ||
| 205 | // }, | ||
| 206 | get sortChildren() { | ||
| 207 | return state.sortChildren; | ||
| 208 | }, | ||
| 209 | set sortChildren(value) { | ||
| 210 | state.sortChildren = value; | ||
| 211 | value && state.children.sort(value); | ||
| 212 | state.signal("draw"); | ||
| 213 | }, | ||
| 214 | inc(delta = 1) { | ||
| 215 | state.value += delta; | ||
| 216 | if (state.estimate > 0 && state.value >= state.estimate) { | ||
| 217 | end(); | ||
| 218 | } else { | ||
| 219 | mutate(); | ||
| 220 | } | ||
| 221 | }, | ||
| 222 | log: scope, | ||
| 223 | end: () => void end(), | ||
| 224 | [Symbol.dispose]: () => void end(), | ||
| 225 | }; | ||
| 226 | return [state, binding]; | ||
| 227 | } | ||
| 228 | |||
| 229 | const spinnerFps = 12.5; | ||
| 230 | |||
| 231 | const box = { | ||
| 232 | tee: "├─ ", | ||
| 233 | line: "│ ", | ||
| 234 | langle: "└─ ", | ||
| 235 | }; | ||
| 236 | const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; | ||
| 237 | const fullBar = "█"; | ||
| 238 | const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] | ||
| 239 | .map((frame) => ansi.style(ansi.fgBlue, frame)); | ||
| 240 | |||
| 241 | let rerender: (() => void) | null = null; | ||
| 242 | let globalTimer: ts.Interval | null = null; | ||
| 243 | const globalWidget: log.Widget = { | ||
| 244 | format(now) { | ||
| 245 | let out = ""; | ||
| 246 | for (const top of globalState) { | ||
| 247 | if (top.passive && top.children.length === 0) continue; | ||
| 248 | out += renderMainLine(top, now, 0) + "\n"; | ||
| 249 | out += renderChildren(top, now, []); | ||
| 250 | } | ||
| 251 | return out.trimEnd(); | ||
| 252 | }, | ||
| 253 | onChange(cb) { | ||
| 254 | //const globalTimer = setInterval(cb, 1000 / spinnerFps); | ||
| 255 | //if ("unref" in globalTimer) (globalTimer.unref as () => void)(); | ||
| 256 | rerender = cb; | ||
| 257 | return () => { | ||
| 258 | globalTimer != null && clearInterval(globalTimer); | ||
| 259 | rerender = null; | ||
| 260 | }; | ||
| 261 | }, | ||
| 262 | }; | ||
| 263 | const globalState: State[] = []; | ||
| 264 | |||
| 265 | function renderMainLine(state: State, now: number, depth: number) { | ||
| 266 | if (state.estimate && state.showEstimate) { | ||
| 267 | return ansi.style( | ||
| 268 | ansi.bgBrightBlack + ansi.fgBlue, | ||
| 269 | getUnicodeBar( | ||
| 270 | state.value / state.estimate, | ||
| 271 | Math.max(4, depth > 0 ? 12 : 25), | ||
| 272 | ), | ||
| 273 | ) + | ||
| 274 | ` [${state.value}/${state.estimate}] ` + state.text; | ||
| 275 | } | ||
| 276 | const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; | ||
| 277 | return (depth === 0 ? spinner[frame] + " " : "") + | ||
| 278 | (state.value > 0 ? `[${state.value}] ` : "") + state.text; | ||
| 279 | } | ||
| 280 | |||
| 281 | function renderChildren(state: State, now: number, depth: boolean[]) { | ||
| 282 | let maxHeight = 25; // TODO: | ||
| 283 | let truncated = 0; | ||
| 284 | let out = ""; | ||
| 285 | const { children } = state; | ||
| 286 | if (children.length === 0) return ""; | ||
| 287 | for (let i = 0, { length } = children; i < length; i += 1) { | ||
| 288 | const child = children[i]; | ||
| 289 | if (child.passive && child.children.length === 0) continue; | ||
| 290 | let item = ""; | ||
| 291 | const left = depth.map((x) => x ? box.line : " ").join(""); | ||
| 292 | item += left + (i === length - 1 && !truncated ? box.langle : box.tee); | ||
| 293 | item += renderMainLine(child, now, 1 + depth.length) + "\n"; | ||
| 294 | item += renderChildren(child, now, depth.concat(i < length - 1)); | ||
| 295 | const h = string.countNewlines(item); | ||
| 296 | if (h > maxHeight) { | ||
| 297 | truncated += 1; | ||
| 298 | continue; | ||
| 299 | } | ||
| 300 | const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; | ||
| 301 | for (const line of logLines) { | ||
| 302 | item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + | ||
| 303 | " " + line + "\n"; | ||
| 304 | } | ||
| 305 | maxHeight -= h + logLines.length; | ||
| 306 | out += item; | ||
| 307 | } | ||
| 308 | if (truncated) { | ||
| 309 | out += depth.map((x) => x ? box.line : " ").join("") + box.langle; | ||
| 310 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); | ||
| 311 | } | ||
| 312 | return out; | ||
| 313 | } | ||
| 314 | |||
| 315 | /** | ||
| 316 | * This function is derived from an old program I wrote back in 2020 called `f` | ||
| 317 | * which did ffmpeg handling. It is probably one of the coolest progress bars | ||
| 318 | * ever imagined. | ||
| 319 | */ | ||
| 320 | export function getUnicodeBar(progress: number, width: number) { | ||
| 321 | if (progress >= 1) return fullBar.repeat(width); | ||
| 322 | if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); | ||
| 323 | |||
| 324 | const wholeWidth = Math.floor(progress * width); | ||
| 325 | const remainderWidth = (progress * width) % 1; | ||
| 326 | const partWidth = Math.floor(remainderWidth * 8); | ||
| 327 | let partChar = barChars[partWidth]; | ||
| 328 | if (width - wholeWidth - 1 < 0) partChar = ""; | ||
| 329 | |||
| 330 | const fill = fullBar.repeat(wholeWidth); | ||
| 331 | const empty = " ".repeat(width - wholeWidth - 1); | ||
| 332 | |||
| 333 | return fill + partChar + empty; | ||
| 334 | } | ||
| 335 | |||
| 336 | function topLevelSignaler(this: State, signal: Signal) { | ||
| 337 | if (signal === "draw") { | ||
| 338 | rerender?.(); | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | import * as ts from "../ts.ts"; | ||
| 343 | import * as log from "../log.ts"; | ||
| 344 | import * as headless from "../log/headless.ts"; | ||
| 345 | import * as ansi from "./ansi.ts"; | ||
| 346 | import * as string from "../string.ts"; | ||
lib/readme.md+4-4| ... | @@ -19,10 +19,10 @@ the public api is simply all of the code. an overview: | ... | @@ -19,10 +19,10 @@ the public api is simply all of the code. an overview: |
| 19 | <!-- - `testing` --> | 19 | <!-- - `testing` --> |
| 20 | 20 | ||
| 21 | - `string` | 21 | - `string` |
| 22 | - `term` - logging, terminal, interactive widgets | 22 | - `log` - logging, terminal i/o, interactive widgets |
| 23 | - `term/ansi` - cursor and color constants | 23 | - `log/ansi` - cursor and color constants |
| 24 | - `term/headless` - use `term` and `term/progress` without a pty | 24 | - `log/headless` - use `log` and `log/progress` without a tty |
| 25 | - `term/progress` - progress bar, spinner, and tree | 25 | - `log/progress` - progress bar, spinner, and tree |
| 26 | - `ts` typescript types and general purpose code | 26 | - `ts` typescript types and general purpose code |
| 27 | 27 | ||
| 28 | these libraries are published as is, **without versioning or maintainance | 28 | these libraries are published as is, **without versioning or maintainance |
lib/term.ts deleted-102| ... | @@ -1,102 +0,0 @@ | ||
| 1 | /** informational */ | ||
| 2 | export function info(...args: unknown[]) { | ||
| 3 | globalLog(...args); | ||
| 4 | } | ||
| 5 | /** advisory */ | ||
| 6 | export function warn(...args: unknown[]) { | ||
| 7 | globalLog.warn(...args); | ||
| 8 | } | ||
| 9 | /** failure */ | ||
| 10 | export function error(...args: unknown[]) { | ||
| 11 | globalLog.error(...args); | ||
| 12 | } | ||
| 13 | |||
| 14 | export interface Scope { | ||
| 15 | /** default call signature is informational scope */ | ||
| 16 | (...args: unknown[]): void; | ||
| 17 | /** informational */ | ||
| 18 | info(...args: unknown[]): void; | ||
| 19 | /** advisory */ | ||
| 20 | warn(...args: unknown[]): void; | ||
| 21 | /** failure */ | ||
| 22 | error(...args: unknown[]): void; | ||
| 23 | |||
| 24 | /** prefix */ | ||
| 25 | name: string; | ||
| 26 | scoped(name: string): Scope; | ||
| 27 | } | ||
| 28 | |||
| 29 | /** custom scopes are colored based on the name */ | ||
| 30 | export function scoped(name: string): Scope { | ||
| 31 | return globalLog.scoped(name); | ||
| 32 | } | ||
| 33 | |||
| 34 | /** | ||
| 35 | * no built-in prefix or formatting. ensures the text does not interweave. | ||
| 36 | * data will be flushed in the next frame or when drawing is unlocked. | ||
| 37 | */ | ||
| 38 | export function writeLine(text: string) { | ||
| 39 | globalWidgetHost.writeLine(text); | ||
| 40 | } | ||
| 41 | |||
| 42 | /** | ||
| 43 | * while locked, no widgets will draw. prefer `writeLine`. | ||
| 44 | * this lock is not exclusive. | ||
| 45 | */ | ||
| 46 | export function getDrawLock(): ts.Dispose { | ||
| 47 | return globalWidgetHost.getDrawLock(); | ||
| 48 | } | ||
| 49 | |||
| 50 | /** | ||
| 51 | * a widget is an interactive display that persists at the end | ||
| 52 | * of the log. this can be used to implement status bars, progress | ||
| 53 | * indicators, and other human i/o. only 'format' is required. | ||
| 54 | */ | ||
| 55 | export function startWidget(widget: Widget): ts.Dispose { | ||
| 56 | return globalWidgetHost.startWidget(widget); | ||
| 57 | } | ||
| 58 | |||
| 59 | export interface Widget { | ||
| 60 | /** | ||
| 61 | * return the widget's text. return null to detach the widget. | ||
| 62 | * may get called more often than the specified `fps`. | ||
| 63 | * supports color codes but not ansi cursor movements. | ||
| 64 | */ | ||
| 65 | format( | ||
| 66 | now: ReturnType<typeof performance.now>, | ||
| 67 | focus: boolean, | ||
| 68 | ): string | null; | ||
| 69 | /** 'null' to never update (use 'onChange'). defaults to 12 fps */ | ||
| 70 | fps?: number | null; | ||
| 71 | /** Subscribe to manual widget updates. Call `rerender` when needed. */ | ||
| 72 | onChange?(rerender: () => void): () => void; | ||
| 73 | // /** | ||
| 74 | // * listen for keyboard events. | ||
| 75 | // */ | ||
| 76 | // onKey?(key: string): void; | ||
| 77 | } | ||
| 78 | |||
| 79 | const globalWidgetHost = /* @__PURE__ */ (() => { | ||
| 80 | const widget = headless.widgetHost({ | ||
| 81 | // TODO: consider synchronization and make these async | ||
| 82 | writeOutput: (string) => process.stdout.write(string), | ||
| 83 | writeInteractive: (string) => process.stderr.write(string), | ||
| 84 | now: () => performance.now(), | ||
| 85 | wait: (ms, cb) => { | ||
| 86 | const id = setTimeout(cb, ms); | ||
| 87 | return () => clearTimeout(id); | ||
| 88 | }, | ||
| 89 | getSize: () => process.stderr, | ||
| 90 | }); | ||
| 91 | process.addListener("beforeExit", () => widget.cancel()); | ||
| 92 | process.addListener("exit", () => widget.cancel()); | ||
| 93 | return widget; | ||
| 94 | })(); | ||
| 95 | const globalLog = /* @__PURE__ */ headless.logger({ | ||
| 96 | log: globalWidgetHost, | ||
| 97 | colors: process.stderr.isTTY, | ||
| 98 | }); | ||
| 99 | |||
| 100 | import process from "node:process"; | ||
| 101 | import * as headless from "./term/headless.ts"; | ||
| 102 | import * as ts from "./ts.ts"; | ||
lib/term/ansi.ts deleted-94| ... | @@ -1,94 +0,0 @@ | ||
| 1 | export const reset: string = `\x1b[0m`; | ||
| 2 | |||
| 3 | export const fgReset: string = `\x1b[39m`; | ||
| 4 | export const fgBlack: string = `\x1b[30m`; | ||
| 5 | export const fgRed: string = `\x1b[31m`; | ||
| 6 | export const fgGreen: string = `\x1b[32m`; | ||
| 7 | export const fgYellow: string = `\x1b[33m`; | ||
| 8 | export const fgBlue: string = `\x1b[34m`; | ||
| 9 | export const fgPurple: string = `\x1b[35m`; | ||
| 10 | export const fgCyan: string = `\x1b[36m`; | ||
| 11 | export const fgWhite: string = `\x1b[37m`; | ||
| 12 | export const fgBrightBlack: string = `\x1b[90m`; | ||
| 13 | export const fgBrightRed: string = `\x1b[91m`; | ||
| 14 | export const fgBrightGreen: string = `\x1b[92m`; | ||
| 15 | export const fgBrightYellow: string = `\x1b[93m`; | ||
| 16 | export const fgBrightBlue: string = `\x1b[94m`; | ||
| 17 | export const fgBrightPurple: string = `\x1b[95m`; | ||
| 18 | export const fgBrightCyan: string = `\x1b[96m`; | ||
| 19 | export const fgBrightWhite: string = `\x1b[97m`; | ||
| 20 | export function fg256(n: number): string { | ||
| 21 | return `\x1b[38;5;${n}m`; | ||
| 22 | } | ||
| 23 | export function fgRgb(r: number, g: number, b: number): string { | ||
| 24 | return `\x1b[38;2;${r};${g};${b}m`; | ||
| 25 | } | ||
| 26 | |||
| 27 | export const bgReset: string = `\x1b[49m`; | ||
| 28 | export const bgBlack: string = `\x1b[40m`; | ||
| 29 | export const bgRed: string = `\x1b[41m`; | ||
| 30 | export const bgGreen: string = `\x1b[42m`; | ||
| 31 | export const bgYellow: string = `\x1b[43m`; | ||
| 32 | export const bgBlue: string = `\x1b[44m`; | ||
| 33 | export const bgPurple: string = `\x1b[45m`; | ||
| 34 | export const bgCyan: string = `\x1b[46m`; | ||
| 35 | export const bgWhite: string = `\x1b[47m`; | ||
| 36 | export const bgBrightBlack: string = `\x1b[100m`; | ||
| 37 | export const bgBrightRed: string = `\x1b[101m`; | ||
| 38 | export const bgBrightGreen: string = `\x1b[102m`; | ||
| 39 | export const bgBrightYellow: string = `\x1b[103m`; | ||
| 40 | export const bgBrightBlue: string = `\x1b[104m`; | ||
| 41 | export const bgBrightPurple: string = `\x1b[105m`; | ||
| 42 | export const bgBrightCyan: string = `\x1b[106m`; | ||
| 43 | export const bgBrightWhite: string = `\x1b[107m`; | ||
| 44 | export function bg256(n: number): string { | ||
| 45 | return `\x1b[48;5;${n}m`; | ||
| 46 | } | ||
| 47 | export function bgRgb(r: number, g: number, b: number): string { | ||
| 48 | return `\x1b[48;2;${r};${g};${b}m`; | ||
| 49 | } | ||
| 50 | |||
| 51 | // cursor management | ||
| 52 | export function cursorUp(n: number) { | ||
| 53 | return n ? `\x1b[${n}A` : ""; | ||
| 54 | } | ||
| 55 | export const startOfLine: string = "\r"; | ||
| 56 | export const startOfNextLine: string = "\n"; | ||
| 57 | |||
| 58 | // erasure | ||
| 59 | export const clearToEndOfLine: string = "\x1b[K"; | ||
| 60 | export const clearToStartOfLine: string = "\x1b[1K"; | ||
| 61 | export const clearFullLine: string = "\x1b[2K"; | ||
| 62 | |||
| 63 | // Synchronized Output | ||
| 64 | // https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 | ||
| 65 | export const syncStart: string = "\x1b[?2026h"; | ||
| 66 | export const syncEnd: string = "\x1b[?2026l"; | ||
| 67 | |||
| 68 | export function style(code: string, text: string): string { | ||
| 69 | code = mergeStyles(code); | ||
| 70 | const fg = /[\[;][39]/.test(code); | ||
| 71 | const bg = /[\[;](?:4|10)/.test(code); | ||
| 72 | return code + text + | ||
| 73 | mergeStyles((fg ? fgReset : "") + (bg ? bgReset : "")); | ||
| 74 | } | ||
| 75 | |||
| 76 | /** | ||
| 77 | * Given a string of multiple style ansi codes, combines all of them. | ||
| 78 | * Must be used on a string of only ansi style codes. | ||
| 79 | */ | ||
| 80 | export function mergeStyles(code: string) { | ||
| 81 | let styles = []; | ||
| 82 | while (code.length) { | ||
| 83 | if (!code.startsWith("\x1b[")) { | ||
| 84 | throw new Error("invalid style string: " + JSON.stringify(code)); | ||
| 85 | } | ||
| 86 | const end = code.indexOf("m"); | ||
| 87 | if (end === -1) { | ||
| 88 | throw new Error("invalid style string: " + JSON.stringify(code)); | ||
| 89 | } | ||
| 90 | styles.push(code.slice(2, end)); | ||
| 91 | code = code.slice(end + 1); | ||
| 92 | } | ||
| 93 | return `\x1b[${styles.join(";")}m`; | ||
| 94 | } | ||
lib/term/headless.ts deleted-313| ... | @@ -1,313 +0,0 @@ | ||
| 1 | // This file is under heavy construction. | ||
| 2 | // | ||
| 3 | // Rendering for 'lib/term' is done by specifying all system APIs up front in | ||
| 4 | // an interface and creating an instance of the widget host. The main file | ||
| 5 | // fufills this interface with `node:process` and `globalThis`. | ||
| 6 | // | ||
| 7 | // Some notes on widget rendering: | ||
| 8 | // - Never flush immediately (exception for `getDrawLock`), always do it next tick. | ||
| 9 | // - Maximum of one `wait` call at once. When the expected time suddenly | ||
| 10 | // shrinks, the timer is rescheduled. | ||
| 11 | // - When redrawing widget lines, three tricks are done to reduce flickering: | ||
| 12 | // 1. Tell the terminal not to flicker (ansi.syncStart/syncEnd) | ||
| 13 | // 2. A simple prefix-based diffing algorithm for skipping unchanged text | ||
| 14 | // 3. Avoid clearing a line before redrawing it. | ||
| 15 | // Points 2 and 3 are used for terminals that are slow or have | ||
| 16 | // | ||
| 17 | |||
| 18 | export interface WidgetHostEnv { | ||
| 19 | writeInteractive(text: string): void; | ||
| 20 | writeOutput(text: string): void; | ||
| 21 | now(): ReturnType<typeof performance.now>; | ||
| 22 | /** 0ms indicates "one frame" */ | ||
| 23 | wait(ms: number, cb: () => void): () => void; | ||
| 24 | getSize(): { columns: number; rows: number }; | ||
| 25 | // onInput?(write: (bytes: Uint8Array | string) => void): () => void; | ||
| 26 | } | ||
| 27 | |||
| 28 | /** Not intended to be implemented manually. See `widgetHost` */ | ||
| 29 | export interface WidgetHost { | ||
| 30 | /** | ||
| 31 | * no built-in prefix or formatting. ensures the text does not interweave. | ||
| 32 | * data will be flushed in the next frame or when drawing is unlocked. | ||
| 33 | */ | ||
| 34 | writeLine(text: string): void; | ||
| 35 | /** | ||
| 36 | * while locked, no widgets will draw. prefer `writeLine`. | ||
| 37 | * this lock is not exclusive. | ||
| 38 | */ | ||
| 39 | getDrawLock(): ts.Dispose; | ||
| 40 | /** | ||
| 41 | * a widget is an interactive display that persists at the end | ||
| 42 | * of the log. this can be used to implement status bars, progress | ||
| 43 | * indicators, and other human i/o. only `format` is required. | ||
| 44 | */ | ||
| 45 | startWidget(widget: term.Widget): ts.Dispose; | ||
| 46 | /** stop all widgets and remove all timers. */ | ||
| 47 | cancel(): void; | ||
| 48 | } | ||
| 49 | |||
| 50 | interface WidgetState { | ||
| 51 | frameTime: number; | ||
| 52 | next: number; | ||
| 53 | unsub: (() => void) | null; | ||
| 54 | } | ||
| 55 | |||
| 56 | /** Implement a widget host by providing an environment. */ | ||
| 57 | export function widgetHost(env: WidgetHostEnv): WidgetHost { | ||
| 58 | const { writeOutput, writeInteractive, now, wait, getSize } = env; | ||
| 59 | type Cancel = ReturnType<typeof wait>; | ||
| 60 | |||
| 61 | let timer: Cancel | null = null; | ||
| 62 | |||
| 63 | let locks = 0; | ||
| 64 | let redrawTime = 0; | ||
| 65 | let lastFlush = 0; | ||
| 66 | let buffer = ""; | ||
| 67 | const widgets: term.Widget[] = []; | ||
| 68 | const internals: WidgetState[] = []; | ||
| 69 | let lines: string[] = []; | ||
| 70 | |||
| 71 | function redrawCallback() { | ||
| 72 | timer = null; | ||
| 73 | redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround | ||
| 74 | |||
| 75 | if (!lines.length && !widgets.length) { | ||
| 76 | buffer && writeOutput(buffer); | ||
| 77 | buffer = ""; | ||
| 78 | return; | ||
| 79 | } | ||
| 80 | |||
| 81 | const { columns, rows } = getSize(); | ||
| 82 | let newWidgetLines: string[] = []; | ||
| 83 | let next = Infinity; | ||
| 84 | if (widgets[0]) { | ||
| 85 | for (let w = 0, { length } = widgets; w < length; w += 1) { | ||
| 86 | const outText = widgets[w].format(lastFlush, false); | ||
| 87 | if (!outText) { | ||
| 88 | widgets.splice(w, 1); | ||
| 89 | internals.splice(w, 1)[0].unsub?.(); | ||
| 90 | w -= 1; | ||
| 91 | length -= 1; | ||
| 92 | continue; | ||
| 93 | } | ||
| 94 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); | ||
| 95 | if (rowsLeft === 1) break; | ||
| 96 | const lines = outText.split("\n").slice(0, rowsLeft); | ||
| 97 | newWidgetLines.push( | ||
| 98 | ...lines.map((line) => string.trimToWidth(line, columns - 1)), | ||
| 99 | ); | ||
| 100 | |||
| 101 | next = Math.min(next, internals[w].frameTime); | ||
| 102 | } | ||
| 103 | |||
| 104 | newWidgetLines = newWidgetLines.slice(0, rows - 1); | ||
| 105 | } | ||
| 106 | |||
| 107 | if (next < Infinity) redrawSoon(next); | ||
| 108 | |||
| 109 | if (!newWidgetLines[0]) { | ||
| 110 | if (lines.length) { | ||
| 111 | let clearLinesTop = Math.min( | ||
| 112 | lines.length, | ||
| 113 | string.countNewlines(buffer), | ||
| 114 | ); | ||
| 115 | writeInteractive( | ||
| 116 | ansi.startOfLine + | ||
| 117 | // skip up the shared widget space | ||
| 118 | ansi.cursorUp(lines.length) + | ||
| 119 | // clear the lines to contain `buffer` | ||
| 120 | (ansi.clearFullLine + ansi.startOfNextLine) | ||
| 121 | .repeat(clearLinesTop) + | ||
| 122 | ansi.cursorUp(clearLinesTop), | ||
| 123 | ); | ||
| 124 | lines = []; | ||
| 125 | } | ||
| 126 | buffer && writeOutput(buffer); | ||
| 127 | buffer = ""; | ||
| 128 | return; | ||
| 129 | } | ||
| 130 | |||
| 131 | if (buffer) { | ||
| 132 | // do not perform diffing since the entire screen is moving down | ||
| 133 | // TODO: should i handle wrapping? | ||
| 134 | let clearLinesTop = Math.min( | ||
| 135 | lines.length, | ||
| 136 | string.countNewlines(buffer), | ||
| 137 | ); | ||
| 138 | let clearLinesBottom = Math.min( | ||
| 139 | lines.length - clearLinesTop, | ||
| 140 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | ||
| 141 | ); | ||
| 142 | writeInteractive( | ||
| 143 | ansi.syncStart + ansi.startOfLine + | ||
| 144 | // clear the bottom lines | ||
| 145 | (clearLinesBottom | ||
| 146 | ? ansi.cursorUp(1) + | ||
| 147 | (ansi.clearToEndOfLine + ansi.cursorUp(1)) | ||
| 148 | .repeat(clearLinesBottom) | ||
| 149 | : "") + | ||
| 150 | // skip up the shared widget space | ||
| 151 | ansi.cursorUp(lines.length - clearLinesBottom) + | ||
| 152 | // clear the lines to contain `buffer` | ||
| 153 | (ansi.clearFullLine + ansi.startOfNextLine) | ||
| 154 | .repeat(clearLinesTop) + | ||
| 155 | ansi.cursorUp(clearLinesTop), | ||
| 156 | ); | ||
| 157 | // then write output lines on standard out | ||
| 158 | writeOutput(buffer); | ||
| 159 | writeInteractive( | ||
| 160 | // the widget text | ||
| 161 | newWidgetLines.join("\n") + "\n" + | ||
| 162 | ansi.syncEnd, | ||
| 163 | ); | ||
| 164 | } else { | ||
| 165 | const clearLinesBottom = Math.min( | ||
| 166 | lines.length, | ||
| 167 | Math.max(0, lines.length - (newWidgetLines?.length ?? 0)), | ||
| 168 | ); | ||
| 169 | writeInteractive( | ||
| 170 | ansi.syncStart + ansi.startOfLine + | ||
| 171 | // clear the bottom lines | ||
| 172 | (clearLinesBottom | ||
| 173 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine) | ||
| 174 | .repeat(clearLinesBottom) | ||
| 175 | : "") + | ||
| 176 | // skip up the widget space | ||
| 177 | ansi.cursorUp(lines.length - clearLinesBottom) + | ||
| 178 | // the widget text | ||
| 179 | newWidgetLines.map((x) => | ||
| 180 | (x.includes("\x1b") && !x.endsWith(ansi.reset) | ||
| 181 | ? x + ansi.reset | ||
| 182 | : x) + | ||
| 183 | ansi.clearToEndOfLine + "\n" | ||
| 184 | ).join("") + | ||
| 185 | ansi.syncEnd, | ||
| 186 | ); | ||
| 187 | } | ||
| 188 | lines = newWidgetLines; | ||
| 189 | |||
| 190 | buffer = ""; | ||
| 191 | } | ||
| 192 | |||
| 193 | function redrawSoon(ms: number) { | ||
| 194 | if (locks > 0 || (ms === 0 && timer)) return; | ||
| 195 | const newRedrawTime = now() + ms; | ||
| 196 | if (timer) { | ||
| 197 | if (redrawTime < newRedrawTime) return; | ||
| 198 | timer(); // cancel previous | ||
| 199 | timer = null; | ||
| 200 | } | ||
| 201 | redrawTime = newRedrawTime + 1; | ||
| 202 | timer = wait(ms, redrawCallback); | ||
| 203 | } | ||
| 204 | |||
| 205 | function flushAndClear() { | ||
| 206 | timer?.(); | ||
| 207 | timer = null; | ||
| 208 | if (lines.length > 0) { | ||
| 209 | // clear widgets | ||
| 210 | } | ||
| 211 | if (buffer.length > 0) writeOutput(buffer), buffer = ""; | ||
| 212 | } | ||
| 213 | |||
| 214 | return { | ||
| 215 | writeLine(chunk) { | ||
| 216 | buffer += chunk + "\n"; | ||
| 217 | redrawSoon(0); | ||
| 218 | }, | ||
| 219 | getDrawLock() { | ||
| 220 | if (locks === 0) flushAndClear(); | ||
| 221 | locks += 1; | ||
| 222 | return ts.defer(() => { | ||
| 223 | locks -= 1; | ||
| 224 | if (locks === 0) { | ||
| 225 | if (buffer.length > 0) writeOutput(buffer), buffer = ""; | ||
| 226 | // TODO: reinit widget | ||
| 227 | } | ||
| 228 | }); | ||
| 229 | }, | ||
| 230 | startWidget(w) { | ||
| 231 | if (!widgets.includes(w)) { | ||
| 232 | const state: WidgetState = { | ||
| 233 | next: 0, | ||
| 234 | unsub: null, | ||
| 235 | frameTime: 1000 / (w.fps ?? 0), | ||
| 236 | }; | ||
| 237 | widgets.push(w); | ||
| 238 | internals.push(state); | ||
| 239 | state.unsub = w.onChange?.(() => { | ||
| 240 | state.next = 0; | ||
| 241 | redrawSoon(0); | ||
| 242 | }) ?? null; | ||
| 243 | redrawSoon(0); | ||
| 244 | } | ||
| 245 | return ts.defer(() => { | ||
| 246 | const i = widgets.indexOf(w); | ||
| 247 | widgets.splice(i, 1); | ||
| 248 | internals.splice(i, 1)[0].unsub?.(); | ||
| 249 | redrawSoon(0); | ||
| 250 | }); | ||
| 251 | }, | ||
| 252 | cancel() { | ||
| 253 | flushAndClear(); | ||
| 254 | }, | ||
| 255 | }; | ||
| 256 | } | ||
| 257 | |||
| 258 | export interface LoggerOptions { | ||
| 259 | /** Provide either a line writer or a log writer. */ | ||
| 260 | log: { | ||
| 261 | writeLine: WidgetHost["writeLine"]; | ||
| 262 | } | { | ||
| 263 | writeLog: (prefix: string, level: Level, ...args: unknown[]) => void; | ||
| 264 | }; | ||
| 265 | colors: boolean; | ||
| 266 | } | ||
| 267 | |||
| 268 | export function logger(env: LoggerOptions): term.Scope { | ||
| 269 | const { colors, log } = env; | ||
| 270 | const levels = colors | ||
| 271 | ? [ | ||
| 272 | ansi.style(ansi.fgBlue, "info"), | ||
| 273 | ansi.style(ansi.fgYellow, "warn"), | ||
| 274 | ansi.style(ansi.fgRed, "error"), | ||
| 275 | ] as const | ||
| 276 | : ["info", "warn", "error"] as const; | ||
| 277 | const colon = colors ? ansi.style(ansi.fgBrightBlack, ":") + " " : ": "; | ||
| 278 | const logFn = "writeLog" in log | ||
| 279 | ? log.writeLog | ||
| 280 | : ((prefix: string, level: Level, ...args: unknown[]) => { | ||
| 281 | log.writeLine( | ||
| 282 | (prefix | ||
| 283 | ? prefix + (level > 0 ? levels[level] + colon : "") | ||
| 284 | : (levels[level] + colon)) + util.format(...args), | ||
| 285 | ); | ||
| 286 | }); | ||
| 287 | |||
| 288 | function scoped(name: string): term.Scope { | ||
| 289 | const formatted = name ? name + colon : name; | ||
| 290 | const fn = logFn.bind(null, formatted, 0) as Partial<term.Scope>; | ||
| 291 | fn.info = fn as term.Scope["info"]; | ||
| 292 | fn.warn = logFn.bind(null, formatted, 1); | ||
| 293 | fn.error = logFn.bind(null, formatted, 2); | ||
| 294 | fn.scoped = createSubScope; | ||
| 295 | Object.defineProperty(fn, "name", { value: name }); | ||
| 296 | return fn as term.Scope; | ||
| 297 | } | ||
| 298 | |||
| 299 | function createSubScope(this: term.Scope, name: string) { | ||
| 300 | const { name: parent } = this; | ||
| 301 | return scoped(parent ? parent + "/" + name : name); | ||
| 302 | } | ||
| 303 | |||
| 304 | return scoped(""); | ||
| 305 | } | ||
| 306 | |||
| 307 | type Level = 0 | 1 | 2; | ||
| 308 | |||
| 309 | import * as ansi from "./ansi.ts"; | ||
| 310 | import * as string from "../string.ts"; | ||
| 311 | import * as ts from "../ts.ts"; | ||
| 312 | import * as util from "node:util"; | ||
| 313 | import type * as term from "../term.ts"; | ||
lib/term/progress.sample.ts deleted-71| ... | @@ -1,71 +0,0 @@ | ||
| 1 | const root = progress.start("progress node", { estimate: 30 }); | ||
| 2 | for (let i = 0; i < 10; i += 1) { | ||
| 3 | { | ||
| 4 | const subNodes = Array.from( | ||
| 5 | { length: 4 }, | ||
| 6 | (_) => root.start("subtask " + random()), | ||
| 7 | ); | ||
| 8 | await delay(); | ||
| 9 | subNodes[0]!.end(); | ||
| 10 | const n = subNodes[1]!.start("meowing", { estimate: 7 }); | ||
| 11 | const m = subNodes[1]!.start("purring", { estimate: 7 }); | ||
| 12 | let w: progress.Node | null = null; | ||
| 13 | let x: progress.Node | null = null; | ||
| 14 | let y: progress.Node | null = null; | ||
| 15 | let z: progress.Node | null = null; | ||
| 16 | for (let i = 0; i < 14; i += 1) { | ||
| 17 | if (Math.random() > 0.5 && n.value < n.estimate) { | ||
| 18 | n.inc(); | ||
| 19 | } else { | ||
| 20 | m.inc(); | ||
| 21 | } | ||
| 22 | await short(); | ||
| 23 | if (i === 5) subNodes[3]!.end(); | ||
| 24 | if (i === 3) { | ||
| 25 | x = subNodes[2]!.start("other task"); | ||
| 26 | y = x.start("deeply nested"); | ||
| 27 | z = y.start("job"); | ||
| 28 | } | ||
| 29 | if (i == 8) z?.end(); | ||
| 30 | if (i === 6) w = y!.start("magic"); | ||
| 31 | if (i == 12) y!.end(); | ||
| 32 | if (i === 10) subNodes[3]!.end(); | ||
| 33 | } | ||
| 34 | await delay(); | ||
| 35 | n.inc(); | ||
| 36 | await delay(); | ||
| 37 | n.end(); | ||
| 38 | root.inc(); | ||
| 39 | subNodes.forEach((x) => x.end()); | ||
| 40 | } | ||
| 41 | { | ||
| 42 | const subNodes = Array.from( | ||
| 43 | { length: 8 }, | ||
| 44 | (_) => root.start("subtask with count " + random()), | ||
| 45 | ); | ||
| 46 | subNodes.forEach((x) => x.value = 1); | ||
| 47 | await delay(); | ||
| 48 | subNodes[0]!.end(); | ||
| 49 | for (let i = 0; i < 80; i += 1) { | ||
| 50 | const node = subNodes[Math.floor(Math.random() * 8)]!; | ||
| 51 | node.inc(); | ||
| 52 | if (node.value > 10) node.end(); | ||
| 53 | await short(); | ||
| 54 | } | ||
| 55 | await delay(); | ||
| 56 | subNodes.forEach((x) => x.end()); | ||
| 57 | } | ||
| 58 | } | ||
| 59 | |||
| 60 | async function delay() { | ||
| 61 | await timers.setTimeout(Math.random() * 100 + 200); | ||
| 62 | } | ||
| 63 | async function short() { | ||
| 64 | await timers.setTimeout(Math.random() * 50 + 50); | ||
| 65 | } | ||
| 66 | function random() { | ||
| 67 | return Math.floor(Math.random() * 9999999).toString(32); | ||
| 68 | } | ||
| 69 | |||
| 70 | import * as progress from "./progress.ts"; | ||
| 71 | import * as timers from "node:timers/promises"; | ||
lib/term/progress.ts deleted-346| ... | @@ -1,346 +0,0 @@ | ||
| 1 | // This file is under heavy construction. | ||
| 2 | // | ||
| 3 | // A progress tree based on my past uses with clover console v3 and the Zig | ||
| 4 | // Progress API. Pass `Progress` to functions which should track progress, | ||
| 5 | // calling `.start()` to create child items. Or, multiple top-level progress | ||
| 6 | // bars can be created with the singleton's `start()` function. | ||
| 7 | import * as self from "./progress.ts"; | ||
| 8 | self satisfies Ref; // prove you can use the namespace import as a `Progress` | ||
| 9 | |||
| 10 | /** | ||
| 11 | * creates a new trackable unit of work as a child of this one. | ||
| 12 | * when given an estimate, a progress bar is rendered. | ||
| 13 | */ | ||
| 14 | export function start(text: string, opts?: StartOptions): Node { | ||
| 15 | const [state, node] = newNode(text, opts); | ||
| 16 | globalState.push(state); | ||
| 17 | state.signal = topLevelSignaler; | ||
| 18 | if (rerender) rerender(); | ||
| 19 | else log.startWidget(globalWidget); | ||
| 20 | return node; | ||
| 21 | } | ||
| 22 | |||
| 23 | export interface Ref { | ||
| 24 | /** | ||
| 25 | * creates a new trackable unit of work as a child of this one. | ||
| 26 | * when given an estimate, a progress bar is rendered. | ||
| 27 | */ | ||
| 28 | start(text: string, opts?: StartOptions): Node; | ||
| 29 | } | ||
| 30 | |||
| 31 | export interface Node extends Ref { | ||
| 32 | /** reactive */ | ||
| 33 | text: string; | ||
| 34 | /** reactive */ | ||
| 35 | value: number; | ||
| 36 | /** reactive */ | ||
| 37 | estimate: number; | ||
| 38 | /** | ||
| 39 | * reactive, default `true`. `false` will hides a bar + printing estimate, | ||
| 40 | * but preseving auto-end behavior of `inc` | ||
| 41 | */ | ||
| 42 | showEstimate: boolean; | ||
| 43 | /** | ||
| 44 | * reactive, default `false`. when true, this progress item is hidden if | ||
| 45 | * there are no children. | ||
| 46 | */ | ||
| 47 | passive: boolean; | ||
| 48 | /** reactive. null means decide based on nested depth + terminal height */ | ||
| 49 | maxHeight: null | number; | ||
| 50 | /** reactive. specify sorting for children */ | ||
| 51 | sortChildren: ((a: SortNode, b: SortNode) => number) | null; | ||
| 52 | |||
| 53 | /** defaults to increasing by 1. hitting estimate calls end */ | ||
| 54 | inc(value?: number): void; | ||
| 55 | |||
| 56 | /** A scoped logger set to output inline to this item. */ | ||
| 57 | log: log.Scope; | ||
| 58 | |||
| 59 | /** end this Node or RootNode. ending collapses children */ | ||
| 60 | end(): void; | ||
| 61 | // /** signal that there will be a failure without ending the node */ | ||
| 62 | // pendingFail(): void; | ||
| 63 | // /** end this node with a failure */ | ||
| 64 | // endFail(): void; | ||
| 65 | |||
| 66 | [Symbol.dispose](): void; | ||
| 67 | } | ||
| 68 | |||
| 69 | export interface StartOptions { | ||
| 70 | estimate?: number | undefined | null; | ||
| 71 | } | ||
| 72 | |||
| 73 | /** a node that satisfies the api */ | ||
| 74 | const nullNode: Node = | ||
| 75 | (function start(text: string, { estimate }: StartOptions = {}): Node { | ||
| 76 | return { | ||
| 77 | start, | ||
| 78 | text, | ||
| 79 | value: 0, | ||
| 80 | estimate: estimate ?? 0, | ||
| 81 | showEstimate: true, | ||
| 82 | passive: false, | ||
| 83 | maxHeight: null, | ||
| 84 | sortChildren: null, | ||
| 85 | inc() { | ||
| 86 | this.value += 1; | ||
| 87 | }, | ||
| 88 | log: log.scoped(""), | ||
| 89 | end() {}, | ||
| 90 | [Symbol.dispose]() {}, | ||
| 91 | }; | ||
| 92 | })("root"); | ||
| 93 | |||
| 94 | export { nullNode as null }; | ||
| 95 | |||
| 96 | type SortNode = | ||
| 97 | & Pick< | ||
| 98 | Node, | ||
| 99 | | "text" | ||
| 100 | | "value" | ||
| 101 | | "estimate" | ||
| 102 | | "passive" | ||
| 103 | | "showEstimate" | ||
| 104 | | "sortChildren" | ||
| 105 | > | ||
| 106 | & { children: SortNode[] }; | ||
| 107 | |||
| 108 | type Signal = "draw" | "end" | "endFail" | "pendingFail"; | ||
| 109 | interface State extends SortNode { | ||
| 110 | logs: string[]; | ||
| 111 | children: State[]; | ||
| 112 | parent: State | null; | ||
| 113 | signal(signal: Signal): void; | ||
| 114 | } | ||
| 115 | |||
| 116 | function newNode(text: string, { estimate }: StartOptions = {}): [State, Node] { | ||
| 117 | const state: State = { | ||
| 118 | text, | ||
| 119 | value: 0, | ||
| 120 | estimate: estimate ?? 0, | ||
| 121 | showEstimate: true, | ||
| 122 | passive: false, | ||
| 123 | logs: [], | ||
| 124 | children: [], | ||
| 125 | parent: null, | ||
| 126 | sortChildren: null, | ||
| 127 | signal() { | ||
| 128 | throw new Error("no signaler"); | ||
| 129 | }, | ||
| 130 | }; | ||
| 131 | function end() { | ||
| 132 | if (!state.parent) return; | ||
| 133 | const i = state.parent.children.indexOf(state); | ||
| 134 | if (i === -1) return; | ||
| 135 | state.signal("draw"); | ||
| 136 | state.parent.children.splice(i, 1); | ||
| 137 | state.parent = null; | ||
| 138 | } | ||
| 139 | function mutate() { | ||
| 140 | const { parent } = state; | ||
| 141 | parent?.sortChildren && parent.children.sort(parent.sortChildren); | ||
| 142 | state.signal("draw"); | ||
| 143 | } | ||
| 144 | const scope = headless.logger({ | ||
| 145 | log: { | ||
| 146 | writeLine(line) { | ||
| 147 | state.logs.push(...line.split("\n")); | ||
| 148 | state.signal("draw"); | ||
| 149 | }, | ||
| 150 | }, | ||
| 151 | colors: process.stderr.isTTY, | ||
| 152 | }); | ||
| 153 | const binding: Node = { | ||
| 154 | start(text, opts) { | ||
| 155 | const [child, node] = newNode(text, opts); | ||
| 156 | state.children.push(child); | ||
| 157 | state.sortChildren && state.children.sort(state.sortChildren); | ||
| 158 | child.parent = state; | ||
| 159 | child.signal = state.signal; | ||
| 160 | state.signal("draw"); | ||
| 161 | return node; | ||
| 162 | }, | ||
| 163 | get text() { | ||
| 164 | return state.text; | ||
| 165 | }, | ||
| 166 | set text(value) { | ||
| 167 | state.text = value; | ||
| 168 | mutate(); | ||
| 169 | }, | ||
| 170 | get value() { | ||
| 171 | return state.value; | ||
| 172 | }, | ||
| 173 | set value(value) { | ||
| 174 | state.value = value; | ||
| 175 | mutate(); | ||
| 176 | }, | ||
| 177 | get estimate() { | ||
| 178 | return state.estimate; | ||
| 179 | }, | ||
| 180 | set estimate(value) { | ||
| 181 | state.estimate = value; | ||
| 182 | mutate(); | ||
| 183 | }, | ||
| 184 | get showEstimate() { | ||
| 185 | return state.showEstimate; | ||
| 186 | }, | ||
| 187 | set showEstimate(value) { | ||
| 188 | state.showEstimate = value; | ||
| 189 | mutate(); | ||
| 190 | }, | ||
| 191 | get passive() { | ||
| 192 | return state.passive; | ||
| 193 | }, | ||
| 194 | set passive(value) { | ||
| 195 | state.passive = value; | ||
| 196 | mutate(); | ||
| 197 | }, | ||
| 198 | maxHeight: null, | ||
| 199 | // get maxHeight() { | ||
| 200 | // return state.maxHeight; | ||
| 201 | // }, | ||
| 202 | // set maxHeight(value) { | ||
| 203 | // state.maxHeight = value; | ||
| 204 | // mutate(); | ||
| 205 | // }, | ||
| 206 | get sortChildren() { | ||
| 207 | return state.sortChildren; | ||
| 208 | }, | ||
| 209 | set sortChildren(value) { | ||
| 210 | state.sortChildren = value; | ||
| 211 | value && state.children.sort(value); | ||
| 212 | state.signal("draw"); | ||
| 213 | }, | ||
| 214 | inc(delta = 1) { | ||
| 215 | state.value += delta; | ||
| 216 | if (state.estimate > 0 && state.value >= state.estimate) { | ||
| 217 | end(); | ||
| 218 | } else { | ||
| 219 | mutate(); | ||
| 220 | } | ||
| 221 | }, | ||
| 222 | log: scope, | ||
| 223 | end: () => void end(), | ||
| 224 | [Symbol.dispose]: () => void end(), | ||
| 225 | }; | ||
| 226 | return [state, binding]; | ||
| 227 | } | ||
| 228 | |||
| 229 | const spinnerFps = 12.5; | ||
| 230 | |||
| 231 | const box = { | ||
| 232 | tee: "├─ ", | ||
| 233 | line: "│ ", | ||
| 234 | langle: "└─ ", | ||
| 235 | }; | ||
| 236 | const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; | ||
| 237 | const fullBar = "█"; | ||
| 238 | const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] | ||
| 239 | .map((frame) => ansi.style(ansi.fgBlue, frame)); | ||
| 240 | |||
| 241 | let rerender: (() => void) | null = null; | ||
| 242 | let globalTimer: ts.Interval | null = null; | ||
| 243 | const globalWidget: log.Widget = { | ||
| 244 | format(now) { | ||
| 245 | let out = ""; | ||
| 246 | for (const top of globalState) { | ||
| 247 | if (top.passive && top.children.length === 0) continue; | ||
| 248 | out += renderMainLine(top, now, 0) + "\n"; | ||
| 249 | out += renderChildren(top, now, []); | ||
| 250 | } | ||
| 251 | return out.trimEnd(); | ||
| 252 | }, | ||
| 253 | onChange(cb) { | ||
| 254 | //const globalTimer = setInterval(cb, 1000 / spinnerFps); | ||
| 255 | //if ("unref" in globalTimer) (globalTimer.unref as () => void)(); | ||
| 256 | rerender = cb; | ||
| 257 | return () => { | ||
| 258 | globalTimer != null && clearInterval(globalTimer); | ||
| 259 | rerender = null; | ||
| 260 | }; | ||
| 261 | }, | ||
| 262 | }; | ||
| 263 | const globalState: State[] = []; | ||
| 264 | |||
| 265 | function renderMainLine(state: State, now: number, depth: number) { | ||
| 266 | if (state.estimate && state.showEstimate) { | ||
| 267 | return ansi.style( | ||
| 268 | ansi.bgBrightBlack + ansi.fgBlue, | ||
| 269 | getUnicodeBar( | ||
| 270 | state.value / state.estimate, | ||
| 271 | Math.max(4, depth > 0 ? 12 : 25), | ||
| 272 | ), | ||
| 273 | ) + | ||
| 274 | ` [${state.value}/${state.estimate}] ` + state.text; | ||
| 275 | } | ||
| 276 | const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; | ||
| 277 | return (depth === 0 ? spinner[frame] + " " : "") + | ||
| 278 | (state.value > 0 ? `[${state.value}] ` : "") + state.text; | ||
| 279 | } | ||
| 280 | |||
| 281 | function renderChildren(state: State, now: number, depth: boolean[]) { | ||
| 282 | let maxHeight = 25; // TODO: | ||
| 283 | let truncated = 0; | ||
| 284 | let out = ""; | ||
| 285 | const { children } = state; | ||
| 286 | if (children.length === 0) return ""; | ||
| 287 | for (let i = 0, { length } = children; i < length; i += 1) { | ||
| 288 | const child = children[i]; | ||
| 289 | if (child.passive && child.children.length === 0) continue; | ||
| 290 | let item = ""; | ||
| 291 | const left = depth.map((x) => x ? box.line : " ").join(""); | ||
| 292 | item += left + (i === length - 1 && !truncated ? box.langle : box.tee); | ||
| 293 | item += renderMainLine(child, now, 1 + depth.length) + "\n"; | ||
| 294 | item += renderChildren(child, now, depth.concat(i < length - 1)); | ||
| 295 | const h = string.countNewlines(item); | ||
| 296 | if (h > maxHeight) { | ||
| 297 | truncated += 1; | ||
| 298 | continue; | ||
| 299 | } | ||
| 300 | const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; | ||
| 301 | for (const line of logLines) { | ||
| 302 | item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + | ||
| 303 | " " + line + "\n"; | ||
| 304 | } | ||
| 305 | maxHeight -= h + logLines.length; | ||
| 306 | out += item; | ||
| 307 | } | ||
| 308 | if (truncated) { | ||
| 309 | out += depth.map((x) => x ? box.line : " ").join("") + box.langle; | ||
| 310 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); | ||
| 311 | } | ||
| 312 | return out; | ||
| 313 | } | ||
| 314 | |||
| 315 | /** | ||
| 316 | * This function is derived from an old program I wrote back in 2020 called `f` | ||
| 317 | * which did ffmpeg handling. It is probably one of the coolest progress bars | ||
| 318 | * ever imagined. | ||
| 319 | */ | ||
| 320 | export function getUnicodeBar(progress: number, width: number) { | ||
| 321 | if (progress >= 1) return fullBar.repeat(width); | ||
| 322 | if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); | ||
| 323 | |||
| 324 | const wholeWidth = Math.floor(progress * width); | ||
| 325 | const remainderWidth = (progress * width) % 1; | ||
| 326 | const partWidth = Math.floor(remainderWidth * 8); | ||
| 327 | let partChar = barChars[partWidth]; | ||
| 328 | if (width - wholeWidth - 1 < 0) partChar = ""; | ||
| 329 | |||
| 330 | const fill = fullBar.repeat(wholeWidth); | ||
| 331 | const empty = " ".repeat(width - wholeWidth - 1); | ||
| 332 | |||
| 333 | return fill + partChar + empty; | ||
| 334 | } | ||
| 335 | |||
| 336 | function topLevelSignaler(this: State, signal: Signal) { | ||
| 337 | if (signal === "draw") { | ||
| 338 | rerender?.(); | ||
| 339 | } | ||
| 340 | } | ||
| 341 | |||
| 342 | import * as ts from "../ts.ts"; | ||
| 343 | import * as log from "../term.ts"; | ||
| 344 | import * as headless from "../term/headless.ts"; | ||
| 345 | import * as ansi from "./ansi.ts"; | ||
| 346 | import * as string from "../string.ts"; | ||
run.js+11-11| ... | @@ -41,17 +41,17 @@ try { | ... | @@ -41,17 +41,17 @@ try { |
| 41 | // Init hooks | 41 | // Init hooks |
| 42 | const hot = await import("./framework/hot.ts"); | 42 | const hot = await import("./framework/hot.ts"); |
| 43 | 43 | ||
| 44 | const console = hot.load("@paperclover/console"); | 44 | const log = hot.load("./lib/log.ts"); |
| 45 | globalThis.console["log"] = console.info; | 45 | console.info = log.info; |
| 46 | globalThis.console.info = console.info; | 46 | console.warn = log.warn; |
| 47 | globalThis.console.warn = console.warn; | 47 | console.error = log.error; |
| 48 | globalThis.console.error = console.error; | 48 | console.debug = log.scoped("debug"); |
| 49 | globalThis.console.debug = console.scoped("dbg"); | 49 | console.log = console.debug; |
| 50 | 50 | ||
| 51 | // Load with hooks | 51 | // Load with hooks |
| 52 | if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { | 52 | if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 53 | if (process.argv.length == 2) { | 53 | if (process.argv.length == 2) { |
| 54 | console.error("usage: node run <script> [...args]"); | 54 | log.error("usage: node run <script> [...args]"); |
| 55 | process.exit(1); | 55 | process.exit(1); |
| 56 | } | 56 | } |
| 57 | let found; | 57 | let found; |
| ... | @@ -64,7 +64,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { | ... | @@ -64,7 +64,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 64 | } | 64 | } |
| 65 | } | 65 | } |
| 66 | if (!found) { | 66 | if (!found) { |
| 67 | console.error("Cannot find script: " + process.argv[2]); | 67 | log.error("Cannot find script: " + process.argv[2]); |
| 68 | process.exit(1); | 68 | process.exit(1); |
| 69 | } | 69 | } |
| 70 | process.argv = [process.argv[0], ...process.argv.slice(2)]; | 70 | process.argv = [process.argv[0], ...process.argv.slice(2)]; |
| ... | @@ -80,7 +80,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { | ... | @@ -80,7 +80,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 80 | }, | 80 | }, |
| 81 | ({ address, port }) => { | 81 | ({ address, port }) => { |
| 82 | if (address === "::") address = "::1"; | 82 | if (address === "::") address = "::1"; |
| 83 | console.info( | 83 | log.info( |
| 84 | url.format({ | 84 | url.format({ |
| 85 | protocol, | 85 | protocol, |
| 86 | hostname: address, | 86 | hostname: address, |
| ... | @@ -90,13 +90,13 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { | ... | @@ -90,13 +90,13 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) { |
| 90 | }, | 90 | }, |
| 91 | ); | 91 | ); |
| 92 | } else { | 92 | } else { |
| 93 | console.warn( | 93 | log.warn( |
| 94 | hot.load("path").relative(".", found), | 94 | hot.load("path").relative(".", found), |
| 95 | 'does not export a "main" function', | 95 | 'does not export a "main" function', |
| 96 | ); | 96 | ); |
| 97 | } | 97 | } |
| 98 | } catch (e) { | 98 | } catch (e) { |
| 99 | console.error(util.inspect(e)); | 99 | log.error(e); |
| 100 | } | 100 | } |
| 101 | } | 101 | } |
| 102 | 102 |
src/file-viewer/bin/scan3.ts+3-3| ... | @@ -14,7 +14,7 @@ const sotToken = process.env.CLOVER_SOT_KEY; | ... | @@ -14,7 +14,7 @@ const sotToken = process.env.CLOVER_SOT_KEY; |
| 14 | 14 | ||
| 15 | export async function main() { | 15 | export async function main() { |
| 16 | const start = performance.now(); | 16 | const start = performance.now(); |
| 17 | using _ = term.startWidget({ | 17 | using _ = log.startWidget({ |
| 18 | format: (now) => | 18 | format: (now) => |
| 19 | `paper clover's scan3 [${((now - start) / 1000).toFixed(1)}s]`, | 19 | `paper clover's scan3 [${((now - start) / 1000).toFixed(1)}s]`, |
| 20 | }); | 20 | }); |
| ... | @@ -909,8 +909,8 @@ import * as fs from "#sitegen/fs"; | ... | @@ -909,8 +909,8 @@ import * as fs from "#sitegen/fs"; |
| 909 | import * as subprocess from "#sitegen/subprocess"; | 909 | import * as subprocess from "#sitegen/subprocess"; |
| 910 | import { Path } from "#sitegen/path"; | 910 | import { Path } from "#sitegen/path"; |
| 911 | import * as queue from "../../../lib/queue.ts"; | 911 | import * as queue from "../../../lib/queue.ts"; |
| 912 | import * as term from "../../../lib/term.ts"; | 912 | import * as log from "../../../lib/log.ts"; |
| 913 | import * as progress from "../../../lib/term/progress.ts"; | 913 | import * as progress from "../../../lib/log/progress.ts"; |
| 914 | import * as ts from "../../../lib/ts.ts"; | 914 | import * as ts from "../../../lib/ts.ts"; |
| 915 | 915 | ||
| 916 | import * as child_process from "node:child_process"; | 916 | import * as child_process from "node:child_process"; |
src/file-viewer/ffmpeg.ts+1-1| ... | @@ -177,5 +177,5 @@ import * as readline from "node:readline"; | ... | @@ -177,5 +177,5 @@ import * as readline from "node:readline"; |
| 177 | import * as process from "node:process"; | 177 | import * as process from "node:process"; |
| 178 | import events from "node:events"; | 178 | import events from "node:events"; |
| 179 | import * as path from "node:path"; | 179 | import * as path from "node:path"; |
| 180 | import * as progress from "../../lib/term/progress.ts"; | 180 | import * as progress from "../../lib/log/progress.ts"; |
| 181 | import type { Path } from "#sitegen/path"; | 181 | import type { Path } from "#sitegen/path"; |