authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-09-06 03:52:38-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-14 02:40:47-07:00
log33bef63518232569f6494e9c8a4c48c9db3ec683
treea555bd4659ca86f433a6bcf9bbc31f849ae00d71
parente3ec694572331c182f19033be1521321b2f6c8fb
signature Commit is signed but in an unrecognized format.

feat(lib): new library "log"

this replaces my `console` library as `lib/log`: - log scoping greatly simplified `log.Scope`. each scope has separate `info`, `warn`, and `error`, while still being a callable value - widget system rebuilt using an interface instead of extending a class - replace `Progress` and `Spinner` with unified progress module - api with disposable: `using node = progress.start(title)` - nesting for subtasks by calling `node.start()` - inline logging and customizable display - these three components can be initialized and used headlessly. in the future, it will be able to communicate status over ipc and the network - no external dependencies. optional dependency on `node:process`.

8 files changed, 1096 insertions(+), 0 deletions(-)

lib/jsr.json+3
...@@ -3,5 +3,8 @@...@@ -3,5 +3,8 @@
3 "version": "1.0.0",3 "version": "1.0.0",
4 "license": "MIT",4 "license": "MIT",
5 "exports": {5 "exports": {
6 "./term": "./term.ts",
7 "./term/ansi": "./term/ansi.ts",
8 "./term/headless": "./term/headless.ts"
6 }9 }
7}10}
lib/string.ts created+156
...@@ -0,0 +1,156 @@
1// TODO: this function is not benchmarked
2export function countNewlines(str: string) {
3 let count = 0;
4 for (let i = 0, { length } = str; i < length; i += 1) {
5 if (str[i] === "\n") count += 1;
6 }
7 return count;
8}
9
10export function widthInTerminal(str: string) {
11 let width = 0;
12 if (str.includes("\x1b")) str = str.replace(ansiEscapes, "");
13 for (let i = 0, { length } = str; i < length; i += 1) {
14 const code = str.charCodeAt(i);
15 if (code < 32) continue;
16 if (code < 127) {
17 width += 1;
18 continue;
19 }
20 for (const { segment } of segmenter.segment(str.slice(i))) {
21 if (segment.length === 1) {
22 if (code < 32) continue;
23 if (code < 127) {
24 width += 1;
25 continue;
26 }
27 }
28 width += segmentWidth(segment);
29 }
30 return width;
31 }
32 return width;
33}
34
35export function trimToWidth(str: string, columns: number) {
36 let width = 0;
37 let ansi = false;
38 for (let i = 0, { length } = str; i < length; i += 1) {
39 const code = str.charCodeAt(i);
40 if (code < 32) {
41 if (code === 0x1b) {
42 const esc = str.slice().match(ansiEscapes);
43 if (esc) i += esc[0].length - 1, ansi = true;
44 }
45 continue;
46 }
47 if (code < 127) {
48 width += 1;
49 if (width >= columns) {
50 const result = str.slice(0, i + 1);
51 return ansi ? result + "\x1b[0m" : result;
52 }
53 continue;
54 }
55 // non-ascii path
56 let out = str.slice(0, i);
57 str = str.slice(i);
58 const segments = segmenter.segment(str);
59 {
60 const { segment } = segments.containing(0);
61 width += segmentWidth(segment);
62 if (width > columns) return ansi ? out + "\x1b[0m" : out;
63 out += segment;
64 i = segment.length;
65 }
66 for ({ length } = str; i < length; i += 1) {
67 const code = str.charCodeAt(i);
68 if (code === 0x1b) {
69 const esc = str.slice(i).match(ansiEscapes);
70 if (esc) {
71 i += esc[0].length - 1, ansi = true;
72 out += esc[0];
73 continue;
74 }
75 }
76 const { segment } = segments.containing(i);
77 width += segmentWidth(segment);
78 if (width > columns) return ansi ? out + "\x1b[0m" : out;
79 out += segment;
80 }
81 return out;
82 }
83 return str;
84}
85
86// derived from sindresorhus's wonderful `string-width`
87// https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js
88// https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js
89// MIT license
90const segmenter = /* @__PURE__ */ new Intl.Segmenter();
91const ansiEscapes =
92 /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/\#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/\#&.:=?%@~_]*)*)?(?:\u0007|\u001B\u005C|\u009C))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
93const zeroWidthClusterRegex =
94 /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
95const leadingNonPrintingRegex =
96 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
97const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
98function segmentWidth(segment: string) {
99 if (zeroWidthClusterRegex.test(segment)) return 0;
100 if (rgiEmojiRegex.test(segment)) return 2;
101
102 // deno-fmt-ignore
103 const list = [
104 0x3000, 0x231A, 0x231B, 0x2329, 0x232A, 0x23F0, 0x23F3, 0x25FD, 0x25FE, 0x2614, 0x2615,
105 0x267F, 0x2693, 0x26A1, 0x26AA, 0x26AB, 0x26BD, 0x26BE, 0x26C4, 0x26C5, 0x26CE, 0x26D4,
106 0x26EA, 0x26F2, 0x26F3, 0x26F5, 0x26FA, 0x26FD, 0x2705, 0x270A, 0x270B, 0x2728, 0x274C,
107 0x274E, 0x2757, 0x27B0, 0x27BF, 0x2B1B, 0x2B1C, 0x2B50, 0x2B55, 0x1AFFD, 0x1AFFE, 0x1B132,
108 0x1B155, 0x1F004, 0x1F0CF, 0x1F18E, 0x1F250, 0x1F251, 0x1F3F4, 0x1F440, 0x1F57A, 0x1F595,
109 0x1F596, 0x1F5A4, 0x1F6CC, 0x1F6EB, 0x1F6EC, 0x1F7F0, 0x1FAC8,
110 ];
111 const x = segment.replace(leadingNonPrintingRegex, "").codePointAt(0) ?? 0;
112 return 1 + +(
113 // full
114 x >= 0xFF01 && x <= 0xFF60 || x >= 0xFFE0 && x <= 0xFFE6 ||
115 // wide
116 x >= 0x1100 && x <= 0x115F || x >= 0x23E9 && x <= 0x23EC ||
117 x >= 0x2630 && x <= 0x2637 || x >= 0x2648 && x <= 0x2653 ||
118 x >= 0x268A && x <= 0x268F || x >= 0x2753 && x <= 0x2755 ||
119 x >= 0x2795 && x <= 0x2797 || x >= 0x2E80 && x <= 0x2E99 ||
120 x >= 0x2E9B && x <= 0x2EF3 || x >= 0x2F00 && x <= 0x2FD5 ||
121 x >= 0x2FF0 && x <= 0x2FFF || x >= 0x3001 && x <= 0x303E ||
122 x >= 0x3041 && x <= 0x3096 || x >= 0x3099 && x <= 0x30FF ||
123 x >= 0x3105 && x <= 0x312F || x >= 0x3131 && x <= 0x318E ||
124 x >= 0x3190 && x <= 0x31E5 || x >= 0x31EF && x <= 0x321E ||
125 x >= 0x3220 && x <= 0x3247 || x >= 0x3250 && x <= 0xA48C ||
126 x >= 0xA490 && x <= 0xA4C6 || x >= 0xA960 && x <= 0xA97C ||
127 x >= 0xAC00 && x <= 0xD7A3 || x >= 0xF900 && x <= 0xFAFF ||
128 x >= 0xFE10 && x <= 0xFE19 || x >= 0xFE30 && x <= 0xFE52 ||
129 x >= 0xFE54 && x <= 0xFE66 || x >= 0xFE68 && x <= 0xFE6B ||
130 x >= 0x16FE0 && x <= 0x16FE4 || x >= 0x16FF0 && x <= 0x16FF6 ||
131 x >= 0x17000 && x <= 0x18CD5 || x >= 0x18CFF && x <= 0x18D1E ||
132 x >= 0x18D80 && x <= 0x18DF2 || x >= 0x1AFF0 && x <= 0x1AFF3 ||
133 x >= 0x1AFF5 && x <= 0x1AFFB || x >= 0x1B150 && x <= 0x1B152 ||
134 x >= 0x1B164 && x <= 0x1B167 || x >= 0x1B170 && x <= 0x1B2FB ||
135 x >= 0x1D300 && x <= 0x1D356 || x >= 0x1D360 && x <= 0x1D376 ||
136 x >= 0x1F191 && x <= 0x1F19A || x >= 0x1F200 && x <= 0x1F202 ||
137 x >= 0x1F210 && x <= 0x1F23B || x >= 0x1F240 && x <= 0x1F248 ||
138 x >= 0x1F260 && x <= 0x1F265 || x >= 0x1F300 && x <= 0x1F320 ||
139 x >= 0x1F32D && x <= 0x1F335 || x >= 0x1F337 && x <= 0x1F37C ||
140 x >= 0x1F37E && x <= 0x1F393 || x >= 0x1F3A0 && x <= 0x1F3CA ||
141 x >= 0x1F3CF && x <= 0x1F3D3 || x >= 0x1F3E0 && x <= 0x1F3F0 ||
142 x >= 0x1F3F8 && x <= 0x1F43E || x >= 0x1F442 && x <= 0x1F4FC ||
143 x >= 0x1F4FF && x <= 0x1F53D || x >= 0x1F54B && x <= 0x1F54E ||
144 x >= 0x1F550 && x <= 0x1F567 || x >= 0x1F5FB && x <= 0x1F64F ||
145 x >= 0x1F680 && x <= 0x1F6C5 || x >= 0x1F6D0 && x <= 0x1F6D2 ||
146 x >= 0x1F6D5 && x <= 0x1F6D8 || x >= 0x1F6DC && x <= 0x1F6DF ||
147 x >= 0x1F6F4 && x <= 0x1F6FC || x >= 0x1F7E0 && x <= 0x1F7EB ||
148 x >= 0x1F90C && x <= 0x1F93A || x >= 0x1F93C && x <= 0x1F945 ||
149 x >= 0x1F947 && x <= 0x1F9FF || x >= 0x1FA70 && x <= 0x1FA7C ||
150 x >= 0x1FA80 && x <= 0x1FA8A || x >= 0x1FA8E && x <= 0x1FAC6 ||
151 x >= 0x1FACD && x <= 0x1FADC || x >= 0x1FADF && x <= 0x1FAEA ||
152 x >= 0x1FAEF && x <= 0x1FAF8 || x >= 0x20000 && x <= 0x2FFFD ||
153 x >= 0x30000 && x <= 0x3FFFD || x >= 0x1B000 && x <= 0x1B122 ||
154 list.includes(x)
155 );
156}
lib/term.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 "./term/headless.ts";
102import * as ts from "./ts.ts";
lib/term/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/term/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: 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 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/term/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 "../term.ts";
344import * as headless from "../term/headless.ts";
345import * as ansi from "./ansi.ts";
346import * as string from "../string.ts";
lib/ts.ts created+11
...@@ -0,0 +1,11 @@
1export type Timer = ReturnType<typeof setTimeout>;
2export type Interval = ReturnType<typeof setInterval>;
3
4export interface Dispose extends Disposable {
5 (): void;
6}
7export function defer(fn: () => void): Dispose {
8 const f = fn as Dispose;
9 f[Symbol.dispose] = f;
10 return f;
11}