authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-27 17:28:39-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
log293d92bc8ba73bbf125387b927096b2c67a61081
tree41961b174b861e08976d79c2bda1d63e8bee5a18
parent30b55e0c92fb15df546351106865c3b01fe1e912
signature Commit is signed but in an unrecognized format.

chore(lib): term -> log


15 files changed, 951 insertions(+), 948 deletions(-)

lib/jsr.json+6-3
......@@ -3,8 +3,11 @@
33 "version": "1.0.0",
44 "license": "MIT",
55 "exports": {
6 "./term": "./term.ts",
7 "./term/ansi": "./term/ansi.ts",
8 "./term/headless": "./term/headless.ts"
6 "./log": "./log.ts",
7 "./log/ansi": "./log/ansi.ts",
8 "./log/headless": "./log/headless.ts",
9 "./queue": "./queue.ts",
10 "./string": "./string.ts",
11 "./ts": "./ts.ts"
912 }
1013}
lib/log.ts created+102
......@@ -0,0 +1,102 @@
1/** informational */
2export function info(...args: unknown[]) {
3 globalLog(...args);
4}
5/** advisory */
6export function warn(...args: unknown[]) {
7 globalLog.warn(...args);
8}
9/** failure */
10export function error(...args: unknown[]) {
11 globalLog.error(...args);
12}
13
14export 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 */
30export 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 */
38export 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 */
46export 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 */
55export function startWidget(widget: Widget): ts.Dispose {
56 return globalWidgetHost.startWidget(widget);
57}
58
59export 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
79const 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})();
95const globalLog = /* @__PURE__ */ headless.logger({
96 log: globalWidgetHost,
97 colors: process.stderr.isTTY,
98});
99
100import process from "node:process";
101import * as headless from "./log/headless.ts";
102import * as ts from "./ts.ts";
lib/log/ansi.ts created+94
......@@ -0,0 +1,94 @@
1export const reset: string = `\x1b[0m`;
2
3export const fgReset: string = `\x1b[39m`;
4export const fgBlack: string = `\x1b[30m`;
5export const fgRed: string = `\x1b[31m`;
6export const fgGreen: string = `\x1b[32m`;
7export const fgYellow: string = `\x1b[33m`;
8export const fgBlue: string = `\x1b[34m`;
9export const fgPurple: string = `\x1b[35m`;
10export const fgCyan: string = `\x1b[36m`;
11export const fgWhite: string = `\x1b[37m`;
12export const fgBrightBlack: string = `\x1b[90m`;
13export const fgBrightRed: string = `\x1b[91m`;
14export const fgBrightGreen: string = `\x1b[92m`;
15export const fgBrightYellow: string = `\x1b[93m`;
16export const fgBrightBlue: string = `\x1b[94m`;
17export const fgBrightPurple: string = `\x1b[95m`;
18export const fgBrightCyan: string = `\x1b[96m`;
19export const fgBrightWhite: string = `\x1b[97m`;
20export function fg256(n: number): string {
21 return `\x1b[38;5;${n}m`;
22}
23export function fgRgb(r: number, g: number, b: number): string {
24 return `\x1b[38;2;${r};${g};${b}m`;
25}
26
27export const bgReset: string = `\x1b[49m`;
28export const bgBlack: string = `\x1b[40m`;
29export const bgRed: string = `\x1b[41m`;
30export const bgGreen: string = `\x1b[42m`;
31export const bgYellow: string = `\x1b[43m`;
32export const bgBlue: string = `\x1b[44m`;
33export const bgPurple: string = `\x1b[45m`;
34export const bgCyan: string = `\x1b[46m`;
35export const bgWhite: string = `\x1b[47m`;
36export const bgBrightBlack: string = `\x1b[100m`;
37export const bgBrightRed: string = `\x1b[101m`;
38export const bgBrightGreen: string = `\x1b[102m`;
39export const bgBrightYellow: string = `\x1b[103m`;
40export const bgBrightBlue: string = `\x1b[104m`;
41export const bgBrightPurple: string = `\x1b[105m`;
42export const bgBrightCyan: string = `\x1b[106m`;
43export const bgBrightWhite: string = `\x1b[107m`;
44export function bg256(n: number): string {
45 return `\x1b[48;5;${n}m`;
46}
47export function bgRgb(r: number, g: number, b: number): string {
48 return `\x1b[48;2;${r};${g};${b}m`;
49}
50
51// cursor management
52export function cursorUp(n: number) {
53 return n ? `\x1b[${n}A` : "";
54}
55export const startOfLine: string = "\r";
56export const startOfNextLine: string = "\n";
57
58// erasure
59export const clearToEndOfLine: string = "\x1b[K";
60export const clearToStartOfLine: string = "\x1b[1K";
61export const clearFullLine: string = "\x1b[2K";
62
63// Synchronized Output
64// https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
65export const syncStart: string = "\x1b[?2026h";
66export const syncEnd: string = "\x1b[?2026l";
67
68export 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 */
80export 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
18export 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` */
29export 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
50interface WidgetState {
51 frameTime: number;
52 next: number;
53 unsub: (() => void) | null;
54}
55
56/** Implement a widget host by providing an environment. */
57export 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
258export 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
268export 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
307type Level = 0 | 1 | 2;
308
309import * as ansi from "./ansi.ts";
310import * as string from "../string.ts";
311import * as ts from "../ts.ts";
312import * as util from "node:util";
313import type * as log from "../log.ts";
lib/log/progress.sample.ts created+71
......@@ -0,0 +1,71 @@
1const root = progress.start("progress node", { estimate: 30 });
2for (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
60async function delay() {
61 await timers.setTimeout(Math.random() * 100 + 200);
62}
63async function short() {
64 await timers.setTimeout(Math.random() * 50 + 50);
65}
66function random() {
67 return Math.floor(Math.random() * 9999999).toString(32);
68}
69
70import * as progress from "./progress.ts";
71import * 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.
7import * as self from "./progress.ts";
8self 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 */
14export 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
23export 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
31export 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
69export interface StartOptions {
70 estimate?: number | undefined | null;
71}
72
73/** a node that satisfies the api */
74const 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
94export { nullNode as null };
95
96type SortNode =
97 & Pick<
98 Node,
99 | "text"
100 | "value"
101 | "estimate"
102 | "passive"
103 | "showEstimate"
104 | "sortChildren"
105 >
106 & { children: SortNode[] };
107
108type Signal = "draw" | "end" | "endFail" | "pendingFail";
109interface State extends SortNode {
110 logs: string[];
111 children: State[];
112 parent: State | null;
113 signal(signal: Signal): void;
114}
115
116function 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
229const spinnerFps = 12.5;
230
231const box = {
232 tee: "├─ ",
233 line: "│ ",
234 langle: "└─ ",
235};
236const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
237const fullBar = "█";
238const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
239 .map((frame) => ansi.style(ansi.fgBlue, frame));
240
241let rerender: (() => void) | null = null;
242let globalTimer: ts.Interval | null = null;
243const 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};
263const globalState: State[] = [];
264
265function 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
281function 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 */
320export 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
336function topLevelSignaler(this: State, signal: Signal) {
337 if (signal === "draw") {
338 rerender?.();
339 }
340}
341
342import * as ts from "../ts.ts";
343import * as log from "../log.ts";
344import * as headless from "../log/headless.ts";
345import * as ansi from "./ansi.ts";
346import * 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:
1919<!-- - `testing` -->
2020
2121- `string`
22- `term` - logging, terminal, interactive widgets
23- `term/ansi` - cursor and color constants
24- `term/headless` - use `term` and `term/progress` without a pty
25- `term/progress` - progress bar, spinner, and tree
22- `log` - logging, terminal i/o, interactive widgets
23- `log/ansi` - cursor and color constants
24- `log/headless` - use `log` and `log/progress` without a tty
25- `log/progress` - progress bar, spinner, and tree
2626- `ts` typescript types and general purpose code
2727
2828these libraries are published as is, **without versioning or maintainance
lib/term.ts deleted-102
......@@ -1,102 +0,0 @@
1/** informational */
2export function info(...args: unknown[]) {
3 globalLog(...args);
4}
5/** advisory */
6export function warn(...args: unknown[]) {
7 globalLog.warn(...args);
8}
9/** failure */
10export function error(...args: unknown[]) {
11 globalLog.error(...args);
12}
13
14export 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 */
30export 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 */
38export 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 */
46export 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 */
55export function startWidget(widget: Widget): ts.Dispose {
56 return globalWidgetHost.startWidget(widget);
57}
58
59export 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
79const 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})();
95const globalLog = /* @__PURE__ */ headless.logger({
96 log: globalWidgetHost,
97 colors: process.stderr.isTTY,
98});
99
100import process from "node:process";
101import * as headless from "./term/headless.ts";
102import * as ts from "./ts.ts";
lib/term/ansi.ts deleted-94
......@@ -1,94 +0,0 @@
1export const reset: string = `\x1b[0m`;
2
3export const fgReset: string = `\x1b[39m`;
4export const fgBlack: string = `\x1b[30m`;
5export const fgRed: string = `\x1b[31m`;
6export const fgGreen: string = `\x1b[32m`;
7export const fgYellow: string = `\x1b[33m`;
8export const fgBlue: string = `\x1b[34m`;
9export const fgPurple: string = `\x1b[35m`;
10export const fgCyan: string = `\x1b[36m`;
11export const fgWhite: string = `\x1b[37m`;
12export const fgBrightBlack: string = `\x1b[90m`;
13export const fgBrightRed: string = `\x1b[91m`;
14export const fgBrightGreen: string = `\x1b[92m`;
15export const fgBrightYellow: string = `\x1b[93m`;
16export const fgBrightBlue: string = `\x1b[94m`;
17export const fgBrightPurple: string = `\x1b[95m`;
18export const fgBrightCyan: string = `\x1b[96m`;
19export const fgBrightWhite: string = `\x1b[97m`;
20export function fg256(n: number): string {
21 return `\x1b[38;5;${n}m`;
22}
23export function fgRgb(r: number, g: number, b: number): string {
24 return `\x1b[38;2;${r};${g};${b}m`;
25}
26
27export const bgReset: string = `\x1b[49m`;
28export const bgBlack: string = `\x1b[40m`;
29export const bgRed: string = `\x1b[41m`;
30export const bgGreen: string = `\x1b[42m`;
31export const bgYellow: string = `\x1b[43m`;
32export const bgBlue: string = `\x1b[44m`;
33export const bgPurple: string = `\x1b[45m`;
34export const bgCyan: string = `\x1b[46m`;
35export const bgWhite: string = `\x1b[47m`;
36export const bgBrightBlack: string = `\x1b[100m`;
37export const bgBrightRed: string = `\x1b[101m`;
38export const bgBrightGreen: string = `\x1b[102m`;
39export const bgBrightYellow: string = `\x1b[103m`;
40export const bgBrightBlue: string = `\x1b[104m`;
41export const bgBrightPurple: string = `\x1b[105m`;
42export const bgBrightCyan: string = `\x1b[106m`;
43export const bgBrightWhite: string = `\x1b[107m`;
44export function bg256(n: number): string {
45 return `\x1b[48;5;${n}m`;
46}
47export function bgRgb(r: number, g: number, b: number): string {
48 return `\x1b[48;2;${r};${g};${b}m`;
49}
50
51// cursor management
52export function cursorUp(n: number) {
53 return n ? `\x1b[${n}A` : "";
54}
55export const startOfLine: string = "\r";
56export const startOfNextLine: string = "\n";
57
58// erasure
59export const clearToEndOfLine: string = "\x1b[K";
60export const clearToStartOfLine: string = "\x1b[1K";
61export const clearFullLine: string = "\x1b[2K";
62
63// Synchronized Output
64// https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
65export const syncStart: string = "\x1b[?2026h";
66export const syncEnd: string = "\x1b[?2026l";
67
68export 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 */
80export 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
18export 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` */
29export 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
50interface WidgetState {
51 frameTime: number;
52 next: number;
53 unsub: (() => void) | null;
54}
55
56/** Implement a widget host by providing an environment. */
57export 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
258export 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
268export 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
307type Level = 0 | 1 | 2;
308
309import * as ansi from "./ansi.ts";
310import * as string from "../string.ts";
311import * as ts from "../ts.ts";
312import * as util from "node:util";
313import type * as term from "../term.ts";
lib/term/progress.sample.ts deleted-71
......@@ -1,71 +0,0 @@
1const root = progress.start("progress node", { estimate: 30 });
2for (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
60async function delay() {
61 await timers.setTimeout(Math.random() * 100 + 200);
62}
63async function short() {
64 await timers.setTimeout(Math.random() * 50 + 50);
65}
66function random() {
67 return Math.floor(Math.random() * 9999999).toString(32);
68}
69
70import * as progress from "./progress.ts";
71import * 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.
7import * as self from "./progress.ts";
8self 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 */
14export 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
23export 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
31export 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
69export interface StartOptions {
70 estimate?: number | undefined | null;
71}
72
73/** a node that satisfies the api */
74const 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
94export { nullNode as null };
95
96type SortNode =
97 & Pick<
98 Node,
99 | "text"
100 | "value"
101 | "estimate"
102 | "passive"
103 | "showEstimate"
104 | "sortChildren"
105 >
106 & { children: SortNode[] };
107
108type Signal = "draw" | "end" | "endFail" | "pendingFail";
109interface State extends SortNode {
110 logs: string[];
111 children: State[];
112 parent: State | null;
113 signal(signal: Signal): void;
114}
115
116function 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
229const spinnerFps = 12.5;
230
231const box = {
232 tee: "├─ ",
233 line: "│ ",
234 langle: "└─ ",
235};
236const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
237const fullBar = "█";
238const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
239 .map((frame) => ansi.style(ansi.fgBlue, frame));
240
241let rerender: (() => void) | null = null;
242let globalTimer: ts.Interval | null = null;
243const 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};
263const globalState: State[] = [];
264
265function 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
281function 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 */
320export 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
336function topLevelSignaler(this: State, signal: Signal) {
337 if (signal === "draw") {
338 rerender?.();
339 }
340}
341
342import * as ts from "../ts.ts";
343import * as log from "../term.ts";
344import * as headless from "../term/headless.ts";
345import * as ansi from "./ansi.ts";
346import * as string from "../string.ts";
run.js+11-11
......@@ -41,17 +41,17 @@ try {
4141// Init hooks
4242const hot = await import("./framework/hot.ts");
4343
44const console = hot.load("@paperclover/console");
45globalThis.console["log"] = console.info;
46globalThis.console.info = console.info;
47globalThis.console.warn = console.warn;
48globalThis.console.error = console.error;
49globalThis.console.debug = console.scoped("dbg");
44const log = hot.load("./lib/log.ts");
45console.info = log.info;
46console.warn = log.warn;
47console.error = log.error;
48console.debug = log.scoped("debug");
49console.log = console.debug;
5050
5151// Load with hooks
5252if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
5353 if (process.argv.length == 2) {
54 console.error("usage: node run <script> [...args]");
54 log.error("usage: node run <script> [...args]");
5555 process.exit(1);
5656 }
5757 let found;
......@@ -64,7 +64,7 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
6464 }
6565 }
6666 if (!found) {
67 console.error("Cannot find script: " + process.argv[2]);
67 log.error("Cannot find script: " + process.argv[2]);
6868 process.exit(1);
6969 }
7070 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))) {
8080 },
8181 ({ address, port }) => {
8282 if (address === "::") address = "::1";
83 console.info(
83 log.info(
8484 url.format({
8585 protocol,
8686 hostname: address,
......@@ -90,13 +90,13 @@ if (process.argv[1].startsWith(import.meta.filename.slice(0, -".js".length))) {
9090 },
9191 );
9292 } else {
93 console.warn(
93 log.warn(
9494 hot.load("path").relative(".", found),
9595 'does not export a "main" function',
9696 );
9797 }
9898 } catch (e) {
99 console.error(util.inspect(e));
99 log.error(e);
100100 }
101101}
102102
src/file-viewer/bin/scan3.ts+3-3
......@@ -14,7 +14,7 @@ const sotToken = process.env.CLOVER_SOT_KEY;
1414
1515export async function main() {
1616 const start = performance.now();
17 using _ = term.startWidget({
17 using _ = log.startWidget({
1818 format: (now) =>
1919 `paper clover's scan3 [${((now - start) / 1000).toFixed(1)}s]`,
2020 });
......@@ -909,8 +909,8 @@ import * as fs from "#sitegen/fs";
909909import * as subprocess from "#sitegen/subprocess";
910910import { Path } from "#sitegen/path";
911911import * as queue from "../../../lib/queue.ts";
912import * as term from "../../../lib/term.ts";
913import * as progress from "../../../lib/term/progress.ts";
912import * as log from "../../../lib/log.ts";
913import * as progress from "../../../lib/log/progress.ts";
914914import * as ts from "../../../lib/ts.ts";
915915
916916import * as child_process from "node:child_process";
src/file-viewer/ffmpeg.ts+1-1
......@@ -177,5 +177,5 @@ import * as readline from "node:readline";
177177import * as process from "node:process";
178178import events from "node:events";
179179import * as path from "node:path";
180import * as progress from "../../lib/term/progress.ts";
180import * as progress from "../../lib/log/progress.ts";
181181import type { Path } from "#sitegen/path";