authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-15 23:36:01-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2025-10-18 10:45:04-07:00
loga109b1aeeb0f22f9c20f5c5a3312ca0d810e0d86
tree41636b28fb41f7996b2857ad592021bfcab7218d
parent3054df53b55f49b1421c222591bbd4d7f7e935c0
signature Commit is signed but in an unrecognized format.

chore(lib): promote `progress` as a top level module `lib/progress`


19 files changed, 513 insertions(+), 506 deletions(-)

framework/bundle.ts+1-1
......@@ -401,5 +401,5 @@ import * as sg from "#sitegen";
401401import type { PageOrView } from "./generate.ts";
402402import type * as view from "#sitegen/view";
403403import * as meta from "lib/render/meta.ts";
404import * as progress from "lib/log/progress.ts";
404import * as progress from "lib/progress.ts";
405405import { ASSERT, UNWRAP } from "lib/assert.ts";
framework/font.ts+1-1
......@@ -216,5 +216,5 @@ import * as incr from "./incremental.ts";
216216import * as crypto from "node:crypto";
217217import * as async from "lib/async.ts";
218218import * as error from "lib/error.ts";
219import * as progress from "../lib/log/progress.ts";
219import * as progress from "../lib/progress.ts";
220220import { ASSERT } from "lib/assert.ts";
framework/generate.ts+1-1
......@@ -401,7 +401,7 @@ import * as meta from "lib/render/meta.ts";
401401import * as render from "lib/render.ts";
402402import * as sg from "#sitegen";
403403import type { FileItem } from "#sitegen";
404import * as progress from "../lib/log/progress.ts";
404import * as progress from "../lib/progress.ts";
405405
406406import * as bundle from "./bundle.ts";
407407import * as css from "./css.ts";
framework/incremental.ts+1-1
......@@ -736,7 +736,7 @@ import * as util from "node:util";
736736import * as crypto from "node:crypto";
737737import * as zlib from "node:zlib";
738738
739import * as progress from "lib/log/progress.ts";
739import * as progress from "lib/progress.ts";
740740import * as stack from "lib/log/stack.ts";
741741import { formatSize } from "@/file-viewer/format.ts";
742742
lib/deno.json+4-3
......@@ -1,7 +1,7 @@
11{
22 "name": "@clo/lib",
33 "description": "high quality typescript libraries",
4 "version": "2.0.0",
4 "version": "3.0.0",
55 "license": "MIT",
66 "exports": {
77 "./assert.ts": "./assert.ts",
......@@ -9,11 +9,11 @@
99 "./error.ts": "./error.ts",
1010 "./log.ts": "./log.ts",
1111 "./log/headless.ts": "./log/headless.ts",
12 "./log/progress.ts": "./log/progress.ts",
1312 "./log/stack.ts": "./log/stack.ts",
1413 "./Lru.ts": "./Lru.ts",
1514 "./mime.ts": "./mime.ts",
1615 "./node.ts": "./node.ts",
16 "./progress.ts": "./progress.ts",
1717 "./queue.ts": "./queue.ts",
1818 "./render.ts": "./render.ts",
1919 "./render/jsx-runtime.ts": "./render/jsx-runtime.ts",
......@@ -37,4 +37,5 @@
3737 "readme.npm.md",
3838 "deno.lock"
3939 ]
40}
\ No newline at end of file
40}
41
lib/log.ts+1-1
......@@ -1,6 +1,6 @@
11/**
22 * By using `lib/log.ts`, your application gets easy scoped logging as well as
3 * integration with terminal widgets such as `lib/log/progress.ts`.
3 * integration with terminal widgets such as `lib/progress.ts`.
44 *
55 * The pattern for using this module is to shadow the global `console` with a
66 * local scope:
lib/log/progress.sample.ts deleted-73
......@@ -1,73 +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 z.log.info("with inline logs");
29 z.log.warn("with inline logs");
30 }
31 if (i == 8) z?.end();
32 if (i === 6) w = y!.start("magic");
33 if (i == 12) y!.end();
34 if (i === 10) subNodes[3]!.end();
35 }
36 await delay();
37 n.inc();
38 await delay();
39 n.end();
40 root.inc();
41 subNodes.forEach((x) => x.end());
42 }
43 {
44 const subNodes = Array.from(
45 { length: 8 },
46 (_) => root.start("subtask with count " + random()),
47 );
48 subNodes.forEach((x) => x.value = 1);
49 await delay();
50 subNodes[0]!.end();
51 for (let i = 0; i < 80; i += 1) {
52 const node = subNodes[Math.floor(Math.random() * 8)]!;
53 node.inc();
54 if (node.value > 10) node.end();
55 await short();
56 }
57 await delay();
58 subNodes.forEach((x) => x.end());
59 }
60}
61
62async function delay() {
63 await timers.setTimeout(Math.random() * 100 + 200);
64}
65async function short() {
66 await timers.setTimeout(Math.random() * 50 + 50);
67}
68function random() {
69 return Math.floor(Math.random() * 9999999).toString(32);
70}
71
72import * as progress from "lib/log/progress.ts";
73import * as timers from "node:timers/promises";
lib/log/progress.ts deleted-416
......@@ -1,416 +0,0 @@
1// This file is under heavy construction.
2// - I am happy with the overall design
3// - Will rename things
4// - Will be re-working the entire module to have a headless
5// core, but still leaving the top level `start` function.
6// - There are an overwhelming amount of bugs
7//
8// a progress tree based on my past uses with clover console v3 and the Zig
9// `Progress` API. Pass `Ref` to functions which should track progress, calling
10// `.start()` to create child items. Or, multiple top-level progress bars can
11// be created with the singleton's `start()` function.
12
13/**
14 * creates a new trackable unit of work as a child of this one.
15 * when given an estimate, a progress bar is rendered.
16 */
17export function start(text: string, opts?: StartOptions): Node {
18 return global.start(text, opts);
19}
20
21export interface Ref {
22 /**
23 * creates a new trackable unit of work as a child of this one.
24 * when given an estimate, a progress bar is rendered.
25 */
26 start(text: string, opts?: StartOptions): Node;
27}
28
29export interface Node extends Ref, Disposable {
30 /** reactive */
31 text: string;
32 /** reactive */
33 value: number;
34 /** reactive */
35 estimate: number;
36 /**
37 * reactive, default `true`. `false` will hides a bar + printing estimate,
38 * but preseving auto-end behavior of `inc`
39 */
40 showEstimate: boolean;
41 /**
42 * reactive, default `false`. when true, this progress item is hidden if
43 * there are no children.
44 */
45 passive: boolean;
46 /**
47 * reactive, default `false`. when true, this progress item is hidden
48 */
49 hidden: boolean;
50 /** reactive. null means decide based on nested depth + terminal height */
51 maxHeight: null | number;
52 /** reactive. specify sorting for children */
53 sortChildren: ((a: SortNode, b: SortNode) => number) | null;
54
55 /** defaults to increasing by 1. hitting estimate calls end */
56 inc(value?: number): void;
57
58 /** A scoped logger set to output inline to this item. */
59 log: log.Scope;
60
61 /** end this Node or RootNode. ending collapses children */
62 end(): void;
63}
64
65export interface StartOptions {
66 value?: number | undefined | null;
67 estimate?: number | undefined | null;
68 passive?: boolean | undefined | null;
69 hidden?: boolean | undefined | null;
70 showEstimate?: boolean | undefined | null;
71 maxHeight?: number | undefined | null;
72 sortChildren?: Node["sortChildren"] | undefined | null;
73}
74
75/** a valid `Node` without any output */
76const nullNode: Node = /* @__PURE__ */
77 (function start(text: string, opts?: StartOptions): Node {
78 const node = newNode(text, opts)[1];
79 node.start = start;
80 node.log = log.scoped("");
81 return node;
82 })("root");
83
84export { nullNode as null };
85
86export type SortNode =
87 & Pick<
88 Node,
89 | "text"
90 | "value"
91 | "estimate"
92 | "passive"
93 | "hidden"
94 | "showEstimate"
95 | "sortChildren"
96 >
97 & { children: SortNode[] };
98
99export interface Internal extends SortNode {
100 logs: string[];
101 children: Internal[];
102 parent: Internal | null;
103 signal(msg: Signal): void;
104}
105
106type Signal =
107 | "draw"
108 | { type: "end"; title: string; logs: string[]; state: Internal }
109 | { type: "line"; line: string };
110
111function newNode(
112 text: string,
113 opts: StartOptions = {},
114): [Internal, Node] {
115 const state: Internal = {
116 text,
117 value: opts.value ?? 0,
118 estimate: opts.estimate ?? 0,
119 showEstimate: opts.showEstimate ?? true,
120 passive: opts.passive ?? false,
121 hidden: opts.hidden ?? false,
122 logs: [],
123 children: [],
124 parent: null,
125 sortChildren: opts.sortChildren ?? null,
126 signal() {
127 throw new Error("no signaler");
128 },
129 };
130 let detached = false;
131 function end() {
132 if (detached) return;
133 let title = state.text;
134 if (state.parent) {
135 const i = state.parent.children.indexOf(state);
136 ASSERT(i !== -1);
137 let p: Internal | null = state;
138 while (p = p.parent) title = p.text + " / " + title;
139 state.parent.children.splice(i, 1);
140 state.parent = null;
141 detached = true;
142 }
143 state.signal({ type: "end", title, logs: state.logs, state });
144 state.logs = [];
145 }
146 function mutate() {
147 const { parent } = state;
148 parent?.sortChildren && parent.children.sort(parent.sortChildren);
149 state.signal("draw");
150 }
151 const scope = headless.logger({
152 log: {
153 writeLine(line) {
154 if (detached) return state.signal({ type: "line", line });
155 state.logs.push(...line.split("\n"));
156 state.signal("draw");
157 },
158 },
159 colors: process.stderr.isTTY,
160 });
161 const binding: Node = {
162 start(text, opts) {
163 const [child, node] = newNode(text, opts);
164 state.children.push(child);
165 state.sortChildren && state.children.sort(state.sortChildren);
166 child.parent = state;
167 child.signal = state.signal;
168 state.signal("draw");
169 return node;
170 },
171 get text() {
172 return state.text;
173 },
174 set text(value) {
175 state.text = value;
176 mutate();
177 },
178 get value() {
179 return state.value;
180 },
181 set value(value) {
182 state.value = value;
183 mutate();
184 },
185 get estimate() {
186 return state.estimate;
187 },
188 set estimate(value) {
189 state.estimate = value;
190 mutate();
191 },
192 get showEstimate() {
193 return state.showEstimate;
194 },
195 set showEstimate(value) {
196 state.showEstimate = value;
197 mutate();
198 },
199 get passive() {
200 return state.passive;
201 },
202 set passive(value) {
203 state.passive = value;
204 mutate();
205 },
206 get hidden() {
207 return state.hidden;
208 },
209 set hidden(value) {
210 state.hidden = value;
211 mutate();
212 },
213 maxHeight: null,
214 // get maxHeight() {
215 // return state.maxHeight;
216 // },
217 // set maxHeight(value) {
218 // state.maxHeight = value;
219 // mutate();
220 // },
221 get sortChildren() {
222 return state.sortChildren;
223 },
224 set sortChildren(value) {
225 state.sortChildren = value;
226 value && state.children.sort(value);
227 state.signal("draw");
228 },
229 inc(delta = 1) {
230 state.value += delta;
231 if (state.estimate > 0 && state.value >= state.estimate) {
232 end();
233 } else {
234 mutate();
235 }
236 },
237 log: scope,
238 end: () => void end(),
239 [Symbol.dispose]: () => void end(),
240 };
241 return [state, binding];
242}
243
244function formatProgress(now: number, list: Internal[]): string {
245 let out = "";
246 for (const top of list) {
247 if (top.passive && !hasChildren(top)) continue;
248 out += renderMainLine(top, now, 0) + "\n";
249 out += renderChildren(top, now, []);
250 }
251 return out.trimEnd();
252}
253
254/** These functions are used to test `lib/log/progress` */
255export namespace internals {
256 export function node(
257 text: string,
258 props: Partial<Internal>,
259 children: Internal[],
260 ): Internal {
261 const [node] = newNode(text);
262 Object.assign(node, props);
263 node.children.push(...children);
264 return node;
265 }
266 export const format = formatProgress;
267}
268
269const spinnerFps = 12.5;
270
271const box = {
272 tee: "├─ ",
273 line: "│ ",
274 langle: "└─ ",
275};
276const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
277const fullBar = "█";
278const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
279 .map((frame) => ansi.style(ansi.fgBlue, frame));
280
281function renderMainLine(state: Internal, now: number, depth: number) {
282 if (state.estimate && state.showEstimate) {
283 return ansi.style(
284 ansi.bgBrightBlack + ansi.fgBlue,
285 getUnicodeBar(
286 state.value / state.estimate,
287 Math.max(4, depth > 0 ? 12 : 25),
288 ),
289 ) +
290 ` [${state.value}/${state.estimate}] ` + state.text;
291 }
292 const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length;
293 return (depth === 0 ? spinner[frame] + " " : "") +
294 (state.value > 0 ? `[${state.value}] ` : "") + state.text;
295}
296
297function hasChildren(states: Internal): boolean {
298 return states.children.some((x) =>
299 !x.hidden && (!x.passive || hasChildren(x))
300 );
301}
302
303function renderChildren(state: Internal, now: number, depth: boolean[]) {
304 let maxHeight = 50; // TODO:
305 let truncated = 0;
306 let out = "";
307 let { children } = state;
308 children = children.filter((x) =>
309 !x.hidden && (!x.passive || hasChildren(x))
310 );
311 if (children.length === 0) return "";
312 for (let i = 0, { length } = children; i < length; i += 1) {
313 const child = UNWRAP(children[i]);
314 let item = "";
315 const left = depth.map((x) => x ? box.line : " ").join("");
316 item += left + (i === length - 1 && !truncated ? box.langle : box.tee);
317 item += renderMainLine(child, now, 1 + depth.length) + "\n";
318 item += renderChildren(child, now, depth.concat(i < length - 1));
319 const h = string.countNewlines(item);
320 if (h > maxHeight) {
321 truncated += 1;
322 continue;
323 }
324 const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? [];
325 for (const line of logLines) {
326 item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") +
327 " " + line + "\n";
328 }
329 maxHeight -= h + logLines.length;
330 out += item;
331 }
332 if (truncated) {
333 out += depth.map((x) => x ? box.line : " ").join("") + box.langle;
334 out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`);
335 }
336 return out;
337}
338
339/**
340 * This function is derived from an old program I wrote back in 2020 called `f`
341 * which did ffmpeg handling. It is probably one of the coolest progress bars
342 * ever imagined.
343 */
344export function getUnicodeBar(progress: number, width: number): string {
345 if (progress >= 1) return fullBar.repeat(width);
346 if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width);
347
348 const wholeWidth = Math.floor(progress * width);
349 const remainderWidth = (progress * width) % 1;
350 const partWidth = Math.floor(remainderWidth * 8);
351 let partChar = barChars[partWidth];
352 if (width - wholeWidth - 1 < 0) partChar = "";
353
354 const fill = fullBar.repeat(wholeWidth);
355 const empty = " ".repeat(width - wholeWidth - 1);
356
357 return fill + partChar + empty;
358}
359
360/** Run a progress tree without a dependency on a TTY */
361export class Headless {
362 constructor(
363 /**
364 * `startWidget` is only ever called from `start`
365 * `writeLine` is used for logs from completed nodes
366 */
367 public env: Pick<headless.WidgetHost, "writeLine" | "startWidget">,
368 ) {}
369
370 active: Internal[] = [];
371
372 start(text: string, opts?: StartOptions): Node {
373 const [state, node] = newNode(text, opts);
374 this.active.push(state);
375 state.signal = this.#signal;
376 const rerender = this.#rerender;
377 if (rerender) rerender();
378 else log.startWidget(this.widget);
379 return node;
380 }
381
382 widget: log.Widget = {
383 format: (now) => formatProgress(now, this.active),
384 onChange: (
385 rerender,
386 ) => (this.#rerender = rerender, () => this.#rerender = null),
387 };
388
389 #rerender?: (() => void) | null;
390 #signal = (msg: Signal) => {
391 if (msg === "draw") this.#rerender?.();
392 else if (msg.type === "line") this.env.writeLine(msg.line);
393 else if (msg.type === "end") {
394 const indexOfActive = this.active.indexOf(msg.state);
395 if (indexOfActive !== -1) this.active.splice(indexOfActive, 1);
396 if (msg.logs.length === 0) {
397 this.#rerender?.();
398 return;
399 }
400 const { logs } = msg;
401 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
402 const header = `[${count} from ${msg.title}]`;
403 this.env.writeLine(ansi.style(ansi.fgBrightBlack, header));
404 this.env.writeLine(msg.logs.join("\n"));
405 }
406 };
407}
408
409const global = /** @__PURE__ */ new Headless(log);
410
411import * as process from "node:process";
412import * as log from "lib/log.ts";
413import * as headless from "lib/log/headless.ts";
414import * as ansi from "lib/string/ansi.ts";
415import * as string from "lib/string.ts";
416import { ASSERT, UNWRAP } from "lib/assert.ts";
lib/progress.sample.ts created+73
......@@ -0,0 +1,73 @@
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 z.log.info("with inline logs");
29 z.log.warn("with inline logs");
30 }
31 if (i == 8) z?.end();
32 if (i === 6) w = y!.start("magic");
33 if (i == 12) y!.end();
34 if (i === 10) subNodes[3]!.end();
35 }
36 await delay();
37 n.inc();
38 await delay();
39 n.end();
40 root.inc();
41 subNodes.forEach((x) => x.end());
42 }
43 {
44 const subNodes = Array.from(
45 { length: 8 },
46 (_) => root.start("subtask with count " + random()),
47 );
48 subNodes.forEach((x) => x.value = 1);
49 await delay();
50 subNodes[0]!.end();
51 for (let i = 0; i < 80; i += 1) {
52 const node = subNodes[Math.floor(Math.random() * 8)]!;
53 node.inc();
54 if (node.value > 10) node.end();
55 await short();
56 }
57 await delay();
58 subNodes.forEach((x) => x.end());
59 }
60}
61
62async function delay() {
63 await timers.setTimeout(Math.random() * 100 + 200);
64}
65async function short() {
66 await timers.setTimeout(Math.random() * 50 + 50);
67}
68function random() {
69 return Math.floor(Math.random() * 9999999).toString(32);
70}
71
72import * as progress from "lib/progress.ts";
73import * as timers from "node:timers/promises";
lib/progress.ts created+416
......@@ -0,0 +1,416 @@
1// This file is under heavy construction.
2// - I am happy with the overall design
3// - Will rename things
4// - Will be re-working the entire module to have a headless
5// core, but still leaving the top level `start` function.
6// - There are an overwhelming amount of bugs
7//
8// a progress tree based on my past uses with clover console v3 and the Zig
9// `Progress` API. Pass `Ref` to functions which should track progress, calling
10// `.start()` to create child items. Or, multiple top-level progress bars can
11// be created with the singleton's `start()` function.
12
13/**
14 * creates a new trackable unit of work as a child of this one.
15 * when given an estimate, a progress bar is rendered.
16 */
17export function start(text: string, opts?: StartOptions): Node {
18 return global.start(text, opts);
19}
20
21export interface Ref {
22 /**
23 * creates a new trackable unit of work as a child of this one.
24 * when given an estimate, a progress bar is rendered.
25 */
26 start(text: string, opts?: StartOptions): Node;
27}
28
29export interface Node extends Ref, Disposable {
30 /** reactive */
31 text: string;
32 /** reactive */
33 value: number;
34 /** reactive */
35 estimate: number;
36 /**
37 * reactive, default `true`. `false` will hides a bar + printing estimate,
38 * but preseving auto-end behavior of `inc`
39 */
40 showEstimate: boolean;
41 /**
42 * reactive, default `false`. when true, this progress item is hidden if
43 * there are no children.
44 */
45 passive: boolean;
46 /**
47 * reactive, default `false`. when true, this progress item is hidden
48 */
49 hidden: boolean;
50 /** reactive. null means decide based on nested depth + terminal height */
51 maxHeight: null | number;
52 /** reactive. specify sorting for children */
53 sortChildren: ((a: SortNode, b: SortNode) => number) | null;
54
55 /** defaults to increasing by 1. hitting estimate calls end */
56 inc(value?: number): void;
57
58 /** A scoped logger set to output inline to this item. */
59 log: log.Scope;
60
61 /** end this Node or RootNode. ending collapses children */
62 end(): void;
63}
64
65export interface StartOptions {
66 value?: number | undefined | null;
67 estimate?: number | undefined | null;
68 passive?: boolean | undefined | null;
69 hidden?: boolean | undefined | null;
70 showEstimate?: boolean | undefined | null;
71 maxHeight?: number | undefined | null;
72 sortChildren?: Node["sortChildren"] | undefined | null;
73}
74
75/** a valid `Node` without any output */
76const nullNode: Node = /* @__PURE__ */
77 (function start(text: string, opts?: StartOptions): Node {
78 const node = newNode(text, opts)[1];
79 node.start = start;
80 node.log = log.scoped("");
81 return node;
82 })("root");
83
84export { nullNode as null };
85
86export type SortNode =
87 & Pick<
88 Node,
89 | "text"
90 | "value"
91 | "estimate"
92 | "passive"
93 | "hidden"
94 | "showEstimate"
95 | "sortChildren"
96 >
97 & { children: SortNode[] };
98
99export interface Internal extends SortNode {
100 logs: string[];
101 children: Internal[];
102 parent: Internal | null;
103 signal(msg: Signal): void;
104}
105
106type Signal =
107 | "draw"
108 | { type: "end"; title: string; logs: string[]; state: Internal }
109 | { type: "line"; line: string };
110
111function newNode(
112 text: string,
113 opts: StartOptions = {},
114): [Internal, Node] {
115 const state: Internal = {
116 text,
117 value: opts.value ?? 0,
118 estimate: opts.estimate ?? 0,
119 showEstimate: opts.showEstimate ?? true,
120 passive: opts.passive ?? false,
121 hidden: opts.hidden ?? false,
122 logs: [],
123 children: [],
124 parent: null,
125 sortChildren: opts.sortChildren ?? null,
126 signal() {
127 throw new Error("no signaler");
128 },
129 };
130 let detached = false;
131 function end() {
132 if (detached) return;
133 let title = state.text;
134 if (state.parent) {
135 const i = state.parent.children.indexOf(state);
136 ASSERT(i !== -1);
137 let p: Internal | null = state;
138 while (p = p.parent) title = p.text + " / " + title;
139 state.parent.children.splice(i, 1);
140 state.parent = null;
141 detached = true;
142 }
143 state.signal({ type: "end", title, logs: state.logs, state });
144 state.logs = [];
145 }
146 function mutate() {
147 const { parent } = state;
148 parent?.sortChildren && parent.children.sort(parent.sortChildren);
149 state.signal("draw");
150 }
151 const scope = headless.logger({
152 log: {
153 writeLine(line) {
154 if (detached) return state.signal({ type: "line", line });
155 state.logs.push(...line.split("\n"));
156 state.signal("draw");
157 },
158 },
159 colors: process.stderr.isTTY,
160 });
161 const binding: Node = {
162 start(text, opts) {
163 const [child, node] = newNode(text, opts);
164 state.children.push(child);
165 state.sortChildren && state.children.sort(state.sortChildren);
166 child.parent = state;
167 child.signal = state.signal;
168 state.signal("draw");
169 return node;
170 },
171 get text() {
172 return state.text;
173 },
174 set text(value) {
175 state.text = value;
176 mutate();
177 },
178 get value() {
179 return state.value;
180 },
181 set value(value) {
182 state.value = value;
183 mutate();
184 },
185 get estimate() {
186 return state.estimate;
187 },
188 set estimate(value) {
189 state.estimate = value;
190 mutate();
191 },
192 get showEstimate() {
193 return state.showEstimate;
194 },
195 set showEstimate(value) {
196 state.showEstimate = value;
197 mutate();
198 },
199 get passive() {
200 return state.passive;
201 },
202 set passive(value) {
203 state.passive = value;
204 mutate();
205 },
206 get hidden() {
207 return state.hidden;
208 },
209 set hidden(value) {
210 state.hidden = value;
211 mutate();
212 },
213 maxHeight: null,
214 // get maxHeight() {
215 // return state.maxHeight;
216 // },
217 // set maxHeight(value) {
218 // state.maxHeight = value;
219 // mutate();
220 // },
221 get sortChildren() {
222 return state.sortChildren;
223 },
224 set sortChildren(value) {
225 state.sortChildren = value;
226 value && state.children.sort(value);
227 state.signal("draw");
228 },
229 inc(delta = 1) {
230 state.value += delta;
231 if (state.estimate > 0 && state.value >= state.estimate) {
232 end();
233 } else {
234 mutate();
235 }
236 },
237 log: scope,
238 end: () => void end(),
239 [Symbol.dispose]: () => void end(),
240 };
241 return [state, binding];
242}
243
244function formatProgress(now: number, list: Internal[]): string {
245 let out = "";
246 for (const top of list) {
247 if (top.passive && !hasChildren(top)) continue;
248 out += renderMainLine(top, now, 0) + "\n";
249 out += renderChildren(top, now, []);
250 }
251 return out.trimEnd();
252}
253
254/** These functions are used to test `lib/progress` */
255export namespace internals {
256 export function node(
257 text: string,
258 props: Partial<Internal>,
259 children: Internal[],
260 ): Internal {
261 const [node] = newNode(text);
262 Object.assign(node, props);
263 node.children.push(...children);
264 return node;
265 }
266 export const format = formatProgress;
267}
268
269const spinnerFps = 12.5;
270
271const box = {
272 tee: "├─ ",
273 line: "│ ",
274 langle: "└─ ",
275};
276const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
277const fullBar = "█";
278const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
279 .map((frame) => ansi.style(ansi.fgBlue, frame));
280
281function renderMainLine(state: Internal, now: number, depth: number) {
282 if (state.estimate && state.showEstimate) {
283 return ansi.style(
284 ansi.bgBrightBlack + ansi.fgBlue,
285 getUnicodeBar(
286 state.value / state.estimate,
287 Math.max(4, depth > 0 ? 12 : 25),
288 ),
289 ) +
290 ` [${state.value}/${state.estimate}] ` + state.text;
291 }
292 const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length;
293 return (depth === 0 ? spinner[frame] + " " : "") +
294 (state.value > 0 ? `[${state.value}] ` : "") + state.text;
295}
296
297function hasChildren(states: Internal): boolean {
298 return states.children.some((x) =>
299 !x.hidden && (!x.passive || hasChildren(x))
300 );
301}
302
303function renderChildren(state: Internal, now: number, depth: boolean[]) {
304 let maxHeight = 50; // TODO:
305 let truncated = 0;
306 let out = "";
307 let { children } = state;
308 children = children.filter((x) =>
309 !x.hidden && (!x.passive || hasChildren(x))
310 );
311 if (children.length === 0) return "";
312 for (let i = 0, { length } = children; i < length; i += 1) {
313 const child = UNWRAP(children[i]);
314 let item = "";
315 const left = depth.map((x) => x ? box.line : " ").join("");
316 item += left + (i === length - 1 && !truncated ? box.langle : box.tee);
317 item += renderMainLine(child, now, 1 + depth.length) + "\n";
318 item += renderChildren(child, now, depth.concat(i < length - 1));
319 const h = string.countNewlines(item);
320 if (h > maxHeight) {
321 truncated += 1;
322 continue;
323 }
324 const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? [];
325 for (const line of logLines) {
326 item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") +
327 " " + line + "\n";
328 }
329 maxHeight -= h + logLines.length;
330 out += item;
331 }
332 if (truncated) {
333 out += depth.map((x) => x ? box.line : " ").join("") + box.langle;
334 out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`);
335 }
336 return out;
337}
338
339/**
340 * This function is derived from an old program I wrote back in 2020 called `f`
341 * which did ffmpeg handling. It is probably one of the coolest progress bars
342 * ever imagined.
343 */
344export function getUnicodeBar(progress: number, width: number): string {
345 if (progress >= 1) return fullBar.repeat(width);
346 if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width);
347
348 const wholeWidth = Math.floor(progress * width);
349 const remainderWidth = (progress * width) % 1;
350 const partWidth = Math.floor(remainderWidth * 8);
351 let partChar = barChars[partWidth];
352 if (width - wholeWidth - 1 < 0) partChar = "";
353
354 const fill = fullBar.repeat(wholeWidth);
355 const empty = " ".repeat(width - wholeWidth - 1);
356
357 return fill + partChar + empty;
358}
359
360/** Run a progress tree without a dependency on a TTY */
361export class Headless {
362 constructor(
363 /**
364 * `startWidget` is only ever called from `start`
365 * `writeLine` is used for logs from completed nodes
366 */
367 public env: Pick<headless.WidgetHost, "writeLine" | "startWidget">,
368 ) {}
369
370 active: Internal[] = [];
371
372 start(text: string, opts?: StartOptions): Node {
373 const [state, node] = newNode(text, opts);
374 this.active.push(state);
375 state.signal = this.#signal;
376 const rerender = this.#rerender;
377 if (rerender) rerender();
378 else log.startWidget(this.widget);
379 return node;
380 }
381
382 widget: log.Widget = {
383 format: (now) => formatProgress(now, this.active),
384 onChange: (
385 rerender,
386 ) => (this.#rerender = rerender, () => this.#rerender = null),
387 };
388
389 #rerender?: (() => void) | null;
390 #signal = (msg: Signal) => {
391 if (msg === "draw") this.#rerender?.();
392 else if (msg.type === "line") this.env.writeLine(msg.line);
393 else if (msg.type === "end") {
394 const indexOfActive = this.active.indexOf(msg.state);
395 if (indexOfActive !== -1) this.active.splice(indexOfActive, 1);
396 if (msg.logs.length === 0) {
397 this.#rerender?.();
398 return;
399 }
400 const { logs } = msg;
401 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
402 const header = `[${count} from ${msg.title}]`;
403 this.env.writeLine(ansi.style(ansi.fgBrightBlack, header));
404 this.env.writeLine(msg.logs.join("\n"));
405 }
406 };
407}
408
409const global = /** @__PURE__ */ new Headless(log);
410
411import * as process from "node:process";
412import * as log from "lib/log.ts";
413import * as headless from "lib/log/headless.ts";
414import * as ansi from "lib/string/ansi.ts";
415import * as string from "lib/string.ts";
416import { ASSERT, UNWRAP } from "lib/assert.ts";
lib/readme.changes.md+6
......@@ -1,5 +1,11 @@
11# notable changes in clover's typescript library
22
3## v3
4
5### breaking
6
7- promote `log/progress.ts` to top level `progress.ts`
8
39## v2
410
511### breaking
lib/readme.md+1-1
......@@ -19,11 +19,11 @@ depend on each other. there are no external dependencies; code is easy to audit.
1919- `error.ts` - interop with the `unknown` type in error handlers
2020- `log.ts` - logging, terminal i/o, interactive widgets
2121- `log/headless.ts` - use `log` and `log/progress` without a tty
22- `log/progress.ts` - progress bar, spinner, and tree
2322- `log/stack.ts` - stack trace parse and formatter
2423- `Lru.ts` - least recently used cache
2524- `mime.ts` - trimmed database of ext -> mime types
2625- `node.ts` - the Node.js javascript runtime
26- `progress.ts` - cli progress bars, nesting status indication
2727- `queue.ts` - priority queue with rescheduling
2828- `render.ts` - html generation with extensions.
2929- `render/jsx-runtime.ts` - integration with JSX syntax
lib/subprocess/ffmpeg.ts+2-2
......@@ -8,7 +8,7 @@ export interface SpawnOptions {
88/**
99 * Spawn an instance of `ffmpeg` and bind it's live status to the given
1010 * `progress.Node`. Logs are routed through the progress node, and dismissed on
11 * exit. `lib/log/progress.ts` will buffer and colocate the logs when running
11 * exit. `lib/progress.ts` will buffer and colocate the logs when running
1212 * parallel jobs.
1313 *
1414 * await ffmpeg.spawn({
......@@ -194,5 +194,5 @@ import * as readline from "node:readline";
194194import * as process from "node:process";
195195import events from "node:events";
196196import * as path from "node:path";
197import type * as progress from "lib/log/progress.ts";
197import type * as progress from "lib/progress.ts";
198198import * as string from "lib/string.ts";
readme.md+1-1
......@@ -24,7 +24,7 @@ tools power <https://paperclover.net>.
2424 - Dynamic pages. Static pages can be regenerated (and cached) at runtime.
2525 - Font subsetting to reduce page bandwidth.
2626- **General purpose typescript libraries.** (See [`./lib`](./lib/readme.md))
27 - [Logging](./lib/log.ts) and [progress](./lib/log/progress.ts) indicators.
27 - [Logging](./lib/log.ts) and [progress](./lib/progress.ts) indicators.
2828 - [Priority Queue](./lib/queue.ts) with automatic reschedulability.
2929 - [Meta Tags](./framework/lib/meta.ts) and Open Graph generation.
3030 - Databases with a minimal SQLite wrapper.
src/deploy.ts+1-1
......@@ -55,7 +55,7 @@ export async function main() {
5555
5656import * as incr from "../framework/incremental.ts";
5757import { generate } from "../framework/generate.ts";
58import * as progress from "lib/log/progress.ts";
58import * as progress from "lib/progress.ts";
5959import * as subprocess from "lib/subprocess.ts";
6060import * as rsync from "./file-viewer/rsync.ts";
6161import { Path } from "#sitegen/path";
src/file-viewer/bin/scan3.ts+1-1
......@@ -1015,7 +1015,7 @@ import * as subprocess from "lib/subprocess.ts";
10151015import { Path } from "#sitegen/path";
10161016import * as queue from "../../../lib/queue.ts";
10171017import * as log from "../../../lib/log.ts";
1018import * as progress from "../../../lib/log/progress.ts";
1018import * as progress from "lib/progress.ts";
10191019import * as ts from "../../../lib/ts.ts";
10201020
10211021import * as child_process from "node:child_process";
src/file-viewer/models/derived.ts+1-1
......@@ -200,5 +200,5 @@ import { Path } from "#sitegen/path";
200200import * as fs from "node:fs";
201201import * as crypto from "node:crypto";
202202import * as queue from "lib/queue.ts";
203import * as progress from "lib/log/progress.ts";
203import * as progress from "lib/progress.ts";
204204import { ASSERT } from "lib/assert.ts";
src/file-viewer/rsync.ts+1-1
......@@ -183,5 +183,5 @@ export class Parse {
183183import * as child_process from "node:child_process";
184184import * as readline from "node:readline";
185185import events from "node:events";
186import * as progress from "lib/log/progress.ts";
186import * as progress from "lib/progress.ts";
187187import { formatSize } from "@/file-viewer/format.ts";
src/package-lib.ts+1-1
......@@ -219,7 +219,7 @@ export function main() {
219219 );
220220}
221221
222import * as progress from "lib/log/progress.ts";
222import * as progress from "lib/progress.ts";
223223import * as path from "node:path";
224224import * as fs from "node:fs/promises";
225225import * as subprocess from "lib/subprocess.ts";