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> 2026-01-14 01:08:16-08:00
logdb344d63f2e3db38db746bc34d259e6ff5af1e87
treedd222f33e2986c1f17d24a2ff66894553874cbe1
parenta74b35e546be0d1d10c1da28e8daedec77ad69b4
signature Commit is signed but in an unrecognized format.

feat(lib/progress): headless rendering + time estimation

node signaling is done by providing a `progress.Root` to every node, dispatching events to it when the node changes. the root is connected to an observer to construct a UI out of it. there are two apis planned: - `attachToScreen` binds a root to a TTY screen (via the log.Widget API). the primary use of this is to implement the top level `progress.start`. - a serialization system that allows transmitting a `Root` over a wire. this commit was going to include this but it is an unexpectedly large component. - potentially a browser binding like `attachToDocument`. this will not be added in this patch. additionally, resolves #33 by implementing `estimatedTime`

4 files changed, 805 insertions(+), 276 deletions(-)

lib/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/progress.ts";
73import * as timers from "node:timers/promises";
lib/progress.test.ts created+102
......@@ -0,0 +1,102 @@
1// a trivial example of how to use progress
2test("trivial end-to-end example", () => {
3 const { fgBlue: FB, fgReset: FR, reset: R } = ansi;
4 const screen = new testing.MockScreen(); // create a mock terminal
5 const root = new progress.Root(screen); // sync with mock timers
6 progress.attachToScreen(root, screen); // attach `root` to the `screen`
7 assert.equal(screen.waitCalls.length, 0); // attaching does not render
8
9 const a = root.start("hello");
10 const b = root.start("cats");
11 const _ = b.start("meow");
12
13 assert.equal(screen.waitCalls.length, 1);
14
15 screen.expectFrame(1, { merged: "" }); // debounce
16 // TODO: why the full resets?
17 screen.expectFrame(0, {
18 merged: testing.MockScreen.sync([
19 `\r${FB}⠋${FR} hello${R}\n`,
20 `${FB}⠋${FR} cats${R}\n`,
21 "└─ meow\n",
22 ]),
23 });
24
25 screen.expectFrame(80, {
26 merged: testing.MockScreen.sync([
27 `\r` + ansi.cursorUp(3),
28 `${FB}⠙${FR} hello${R}\n`,
29 `${FB}⠙${FR} cats${R}\n`,
30 "└─ meow\n",
31 ]),
32 });
33
34 a.end();
35 b.end();
36
37 screen.expectFrame(80, {
38 merged: testing.MockScreen.sync([
39 `\r` + ansi.cursorUp(1) + ansi.clearFullLine,
40 ansi.cursorUp(1) + ansi.clearFullLine,
41 ansi.cursorUp(1) + ansi.clearFullLine,
42 ]),
43 });
44});
45
46// ## value formatters
47test("slashValueFormatter", ({ mock }) => {
48 const fn = mock.fn((arg: number) => `<${arg}>`);
49
50 const fmt = progress.slashValueFormatter(fn);
51 assert.equal(fmt(250, 0), "<250>");
52 assert.equal(fn.mock.callCount(), 1);
53 assert.equal(fmt(250, 450), "<250>/<450>");
54 assert.equal(fn.mock.callCount(), 3);
55});
56test("bytesValueFormatter", () => {
57 assert.equal(progress.bytesValueFormatter(250, 0), "250B");
58 assert.equal(progress.bytesValueFormatter(0, 1_200_000), "0B/1.2MB");
59});
60test("defaultValueFormatter", () => {
61 assert.equal(progress.defaultValueFormatter(250, 0), "250");
62 assert.equal(progress.defaultValueFormatter(0, 1_200_000), "0/1200000");
63});
64
65test("percentValueFormatter", () => {
66 assert.equal(progress.percentValueFormatter(100, 200), "50%");
67 assert.equal(progress.percentValueFormatter(400, 400), "100%");
68 assert.equal(progress.percentValueFormatter(0, 1_200_000), "0%");
69 assert.equal(progress.percentValueFormatter(0, null), "0%");
70 assert.equal(progress.percentValueFormatter(0.5, null), "50%");
71});
72
73// ## `progress.Ema`
74//
75// the exponential moving average algorithm can be used on its own
76//
77test("Ema", () => {
78 // first sample always returns null, if the progress moves linearly then the
79 // estimated time will be the same.
80 let ema = new progress.Ema();
81 assert.equal(ema.sample(50_000, 0), null);
82 assert.equal(ema.sample(50_010, 0.1), 50_100);
83 assert.equal(ema.sample(50_020, 0.2), 50_100);
84 assert.equal(ema.sample(50_080, 0.8), 50_100);
85
86 // this algorithm estimates somewhat well
87 ema = new progress.Ema();
88 assert.equal(ema.sample(50_000, 0), null);
89 assert.equal(ema.sample(50_010, 0.1), 50_100);
90 assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664);
91 assert.equal(ema.sample(50_050, 0.4), 50_109.7);
92 assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664);
93 assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284);
94 assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857);
95 assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305);
96});
97
98import * as progress from "./progress.ts";
99import { test } from "node:test";
100import assert from "node:assert/strict";
101import * as testing from "./testing.ts";
102import * as ansi from "./string/ansi.ts";
lib/progress.ts+654-203
......@@ -1,29 +1,83 @@
1// For those coming from my blog, check out this video
2// -> https://paperclover.net/file/2025/file%20scanner.mp4?view=embed
3//
4// There is some massive rework pending on this file, check out the PR
5// -> https://git.paperclover.net/clo/sitegen/pulls/48
6//
7// This file is under heavy construction.
8// - I am happy with the overall design
9// - Will rename things
10// - Will be re-working the entire module to have a headless
11// core, but still leaving the top level `start` function.
12// - There are an overwhelming amount of bugs
13//
14// a progress tree based on my past uses with clover console v3 and the Zig
15// `Progress` API. Pass `Ref` to functions which should track progress, calling
16// `.start()` to create child items. Or, multiple top-level progress bars can
17// be created with the singleton's `start()` function.
1/**
2 * a progress reporting API that allows creating CLI spinners and progress bars
3 * that can be easily nested to visualize complex, parallel work. in addition
4 * to providing rich feedback in the terminal, a headless {@linkcode Root} node
5 * can be constructed, which can be used to bind to {@link formatHtml|the DOM}
6 * or transmit progress information {@link encodeStream|across a network}.
7 *
8 * the progress API takes advantage of the `using` keyword to make nodes
9 * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref},
10 * to act as the destination for the sub-node.
11 *
12 * ```ts
13 * async function doSomething(p: progress.Ref) {
14 * using node = p.start("my task");
15 * // ...
16 * await doSomethingElse(node); // node satisfies progress.Ref
17 * // ...
18 * }
19 * async function doSomethingElse(p: progress.Ref) {
20 * using node = p.start("my sub task");
21 * // ...
22 * }
23 *
24 * doSomething(progress); // the progress module satisfies progress.Ref
25 *
26 * import * as progress from "@clo/lib/progress.ts";
27 * ```
28 *
29 * in some cases, such as in `subprocess/ffmpeg.ts`, it may make more sense
30 * to pass in a pre-started node. the `ffmpeg` module, for example, uses the
31 * starting text as a base title and decorates it with encoding stats.
32 *
33 * when using the top-level {@linkcode start|progress.start}, proper
34 * integration is made with `lib/log.ts` so that the progress tree does not
35 * intersect with regular log messages, as well as forwarding each
36 * {@linkcode Node} custom logging scope to the global log, including any
37 * custom redirections.
38 *
39 * this module is under construction. while i am happy with the overall API, it
40 * needs more work and feature development.
41 *
42 * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html).
43 * @module
44 */
1845
1946/**
20 * creates a new trackable unit of work as a child of this one.
21 * when given an estimate, a progress bar is rendered.
47 * creates a new trackable unit of work, visible in the terminal as a spinner
48 * that rests at the end of the log. if `estimate` is given, a progress bar is
49 * shown.
2250 */
2351export function start(text: string, opts?: StartOptions): Node {
2452 return global.start(text, opts);
2553}
2654
55/**
56 * a reference point to create sub-items. this interface is satisfied by
57 * - the `progress` module itself
58 * - a node recieved by `p.start("label")`
59 * - a headless progress runner from `progress.headless()`
60 *
61 * prefer taking `Ref` as a function parameter, so that the function can be
62 * started at the top level, or as a sub-task of an existing node.
63 *
64 * ```ts
65 * function build(p: progress.Ref = progress) {
66 * using _ = p.start("build process");
67 * // ...
68 * }
69 * ```
70 *
71 * sometimes it makes sense to have an API recieve `Node` instead of `Ref`,
72 * specifically if the caller should have some control over the node name.
73 *
74 * ```ts
75 * await ffmpeg.spawn({
76 * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"],
77 * progress: progress.start("encode hello.mov"),
78 * });
79 * ```
80 */
2781export interface Ref {
2882 /**
2983 * creates a new trackable unit of work as a child of this one.
......@@ -32,139 +86,400 @@ export interface Ref {
3286 start(text: string, opts?: StartOptions): Node;
3387}
3488
89/**
90 * control for a progress nooe. all fields are primed with setters to trigger
91 * ui dispatch automatically. to make tools that use `progress.ts` more
92 * modular to a custom progress implementation, many fields are optional.
93 */
3594export interface Node extends Ref, Disposable {
36 /** reactive */
95 /** end this Node or RootNode. ending collapses children. */
96 end(this: Node): void;
97 /** one line of text display. */
3798 text: string;
38 /** reactive */
99 /**
100 * number of items completed. by default this is a count of items, but the
101 * `units` property can be passed to `start` to change how the `value` is
102 * interpretted when displaying this progress node.
103 */
39104 value: number;
40 /** reactive */
41 estimate: number;
42105 /**
43 * reactive, default `true`. `false` will hides a bar + printing estimate,
44 * but preseving auto-end behavior of `inc`
106 * an estimate of how many items this task contains.
107 * when set above `0`, it is shown with a progress bar.
45108 */
46 showEstimate: boolean;
109 total: number;
47110 /**
48 * reactive, default `false`. when true, this progress item is hidden if
111 * defaults to increasing by 1. if incrementing causes `value >= estimate`,
112 * it also calls `end`. use `node.value += 1` if that behavior is not wanted.
113 */
114 inc(this: Node, value?: number): void;
115 /** a scoped logger set to output inline on this item. */
116 readonly log: log.Scope;
117
118 /**
119 * a time for when this node is estimated to be completed, in milliseconds
120 * relative to the unix epoch. by default, this is automatically managed and
121 * made read-only. if `StartOptions` specifies `estimateCompletions: false`,
122 * this field may be mutated.
123 */
124 estimatedTime?: number | null;
125 /**
126 * default `true`. `false` will hides a bar + printing total. this is useful
127 * for preseving auto-end behavior of `inc` without showing the total.
128 */
129 showTotal?: boolean;
130 /**
131 * default `false`. when true, this progress item is hidden if
49132 * there are no children.
50133 */
51 passive: boolean;
134 passive?: boolean;
135 /** default `false`. when true, this progress item is hidden */
136 hidden?: boolean;
137 /** override sorting for children */
138 sortChildren?: ((a: ReadOnlyNode, b: ReadOnlyNode) => number) | null;
52139 /**
53 * reactive, default `false`. when true, this progress item is hidden
140 * specify how `value` and `total` is formed into text.
141 * this has some discoverable presets on `StartOptions.units`.
54142 */
55 hidden: boolean;
56 /** reactive. null means decide based on nested depth + terminal height */
57 maxHeight: null | number;
58 /** reactive. specify sorting for children */
59 sortChildren: ((a: SortNode, b: SortNode) => number) | null;
60
61 /** defaults to increasing by 1. hitting estimate calls end */
62 inc(value?: number): void;
63
64 /** A scoped logger set to output inline to this item. */
65 log: log.Scope;
66
67 /** end this Node or RootNode. ending collapses children */
68 end(): void;
143 valueFormatter?: (value: number, total: number | null) => string;
69144}
70145
146/** override the default field values of {@link Node} on creation. */
71147export interface StartOptions {
148 /** number of items already completed. */
72149 value?: number | undefined | null;
73 estimate?: number | undefined | null;
150 /** when set above `0`, it is shown with a progress bar. */
151 total?: number | undefined | null;
152 /**
153 * presets for common formattings of `value` and `total`.
154 * @default "count"
155 */
156 units?: "count" | "bytes" | "percent";
157 /**
158 * customize how an estimate is given for progress bars. to make
159 * `Node.estimatedTime` mutable, pass `false` here.
160 * @default `null`, which is equivilent to `new progress.Ema()`
161 */
162 estimateCompletion?: EstimationAlgorithm | boolean | null;
163 /**
164 * when true, this progress item is hidden if there are no children.
165 * this makes sense when scheduling items with a priority queue, where a
166 * passive node is only used for grouping, but children are used for
167 * indicating status.
168 * @default false
169 */
74170 passive?: boolean | undefined | null;
171 /** default `false`. when true, this progress item is hidden */
75172 hidden?: boolean | undefined | null;
76 showEstimate?: boolean | undefined | null;
77 maxHeight?: number | undefined | null;
173 /**
174 * default `true`. `false` will hides a bar + printing estimate,
175 * but preseving auto-end behavior of `inc`
176 */
177 showTotal?: boolean | undefined | null;
178 /** specify sorting for children */
78179 sortChildren?: Node["sortChildren"] | undefined | null;
180 /**
181 * specify how `value` and `total` is formed into text.
182 * this has some discoverable presets on `StartOptions.units`.
183 */
184 valueFormatter?: (value: number, total: number | null) => string;
185 /** pre-fill the list of log messages */
186 messages?: log.Message[];
187}
188
189/**
190 * creates a function to use as a `Node.valueFormatter` by using a single
191 * number formatter, and then joining the node's value and total with a "/",
192 * but leaving it out if there is no total.
193 */
194export function slashValueFormatter(fmtNumber: (value: number) => string) {
195 return (value: number, total: number | null) =>
196 value > 0
197 ? fmtNumber(value) +
198 (total != null && total > 0 ? "/" + fmtNumber(total) : "")
199 : "";
200}
201
202/** the formatter used when passing `units: "count"` to {@linkcode start} */
203export const defaultValueFormatter = /* @__PURE__ */
204 slashValueFormatter(String);
205
206/** the formatter used when passing `units: "bytes"` to {@linkcode start} */
207export const bytesValueFormatter = /* @__PURE__ */
208 slashValueFormatter(string.formatByteSize);
209
210/** the formatter used when passing `units: "percent"` to {@linkcode start} */
211export function percentValueFormatter(value: number, total: number | null) {
212 if (total != null && total > 0) {
213 value /= total;
214 }
215 return `${Math.round(value * 1000) / 10}%`;
216}
217
218/**
219 * {@linkcode Root} allows overriding it's timing functions, which are used for
220 * mostly for rendering and streaming adapters.
221 */
222export interface RootOptions {
223 delay?: typeof async.delay;
224 /** used for debouncing and UI. time estimation does NOT use this method */
225 now?: typeof performance.now;
79226}
80227
81/** a valid `Node` without any output */
82const nullNode: Node = /* @__PURE__ */
83 (function start(text: string, opts?: StartOptions): Node {
84 const node = newNode(text, opts)[1];
85 node.start = start;
86 node.log = log.scoped("");
228/**
229 * a headless progress node. when the state of the tree changes, the `change`
230 * event is emitted (batched with many changes). the primary use case of using
231 * a custom `Root`s is to stream a task's progress over a network or process
232 * IPC using {@linkcode encodeByteStream}/{@linkcode encodeEventStream}.
233 * another use case is to render a progress task differently.
234 *
235 * custom events may be dispatched on the `Root`, but this is only useful when
236 * streaming, since the streams will carry any custom events.
237 */
238export class Root<
239 Result = void,
240 Map extends Events.Map = ts.EmptyObject,
241> extends Events<MergeRootEvents<Result, Map>> {
242 delay: typeof async.delay;
243 now: typeof performance.now;
244 #status: "active" | "end" | "error" = "active";
245 #active: Internal[] = [];
246 #debounce: async.Cancelable<void> | null = null;
247
248 constructor({ delay, now }: RootOptions = {}) {
249 super();
250 this.delay = delay ?? async.delay;
251 this.now = now ?? performance.now.bind(performance);
252 this.on("node-end", (state) => {
253 if (state.parent) return;
254 const i = this.#active.indexOf(state as Internal);
255 ASSERT(i !== -1);
256 this.#active.splice(i, 1);
257 });
258 }
259
260 /** the caller may not mutate `Root`'s state */
261 get active(): readonly ReadOnlyNode[] {
262 return this.#active;
263 }
264
265 /**
266 * creates a new trackable unit of work at the top level of this root.
267 * when given an estimate, a progress bar is rendered.
268 */
269 start(text: string, opts?: StartOptions): Node {
270 const [state, node] = newNode(this, text, opts);
271 this.#active.push(state);
272 this.emit("node-start", state, null);
87273 return node;
88 })("root");
274 }
275
276 /**
277 * end this root, as well as all children nodes. this emits the `end` event
278 * with the provided value, JSON-serializing it if connected via
279 * `encodeEventStream` or `encodeByteStream`.
280 *
281 * this function is pre-bound so that it can be passed to `then`:
282 * ```ts
283 * const root = new Root();
284 * void asyncFunction().then(root.end, root.error);
285 * ```
286 */
287 end = (result: Result): void => {
288 this.emit("end", result);
289 };
290
291 /**
292 * end this root, as well as all children nodes. this emits the `error` event.
293 */
294 error = (error: unknown): void => {
295 this.emit("error", error);
296 };
297
298 /** convert this root into a promise. */
299 asPromise(): Promise<Result> {
300 return this.once("end").then((x) => x[0]);
301 }
302
303 /**
304 * emit an event on the specified channel. behavior is not defined when manually
305 * emitting one of `progress`'s built in events.
306 */
307 override emit<C extends keyof MergeRootEvents<Result, Map>>(
308 channel: C,
309 ...args: MergeRootEvents<Result, Map>[C]
310 ): void {
311 ASSERT(this.#status === "active");
312 if (channel !== "change" && rootEvents.has(channel)) this.#emitChangeSoon();
313 if (channel === "error" || channel === "end") {
314 this.#end();
315 this.#status = channel;
316 }
317 super.emit(channel, ...args);
318 }
319
320 #end() {
321 const hasActive = this.#active.length > 0 || !this.#debounce;
322 for (const active of this.#active) endNode(this, active);
323 this.#active.splice(0, this.#active.length);
324 this.#debounce?.cancel();
325 if (hasActive) this.emit("change", this.#active);
326 }
327
328 #emitChangeSoon = () => {
329 if (this.#debounce) return;
330 (this.#debounce = this.delay(1))
331 .then(() => {
332 this.#debounce = null;
333 this.emit("change", this.#active);
334 });
335 };
336}
337
338export type RootEventMap<Result = void> = {
339 // this event is the debounced "ready to re-render" event. do not emit.
340 "change": [rootNodes: readonly ReadOnlyNode[]];
341 // these events are emitted without a debounce, and are used for general
342 // communication within `Root`'s implementation. do not emit.
343 "node-start": [newNode: ReadOnlyNode, parentNode: ReadOnlyNode | null];
344 "node-change": [node: ReadOnlyNode, key: keyof ReadOnlyNode];
345 "node-end": [node: ReadOnlyNode];
346 "node-detached-log": [msg: log.Message];
347 // these events are used for result communication. they can be sent via
348 // `emit` or the shorthand methods `end` and `error`. sending either will put
349 // the `Root` into a "done" state.
350 "end": [result: Result];
351 "error": [error: unknown];
352};
353
354const rootEvents: ReadonlySet<unknown> = new Set([
355 "change",
356 "node-start",
357 "node-change",
358 "node-end",
359 "node-detached-log",
360 "end",
361 "error",
362]);
89363
90export { nullNode as null };
364type MergeRootEvents<Result, Map extends Events.Map> = {
365 [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap
366 ? RootEventMap<Result>[K]
367 : Map[K];
368};
91369
92export type SortNode =
370/** a read-only version of {@linkcode Node}, provided to renderers. */
371export type ReadOnlyNode = Readonly<
93372 & Pick<
94 Node,
373 Required<Node>,
95374 | "text"
96375 | "value"
97 | "estimate"
376 | "total"
377 | "estimatedTime"
98378 | "passive"
99379 | "hidden"
100 | "showEstimate"
380 | "showTotal"
101381 | "sortChildren"
382 | "valueFormatter"
102383 >
103 & { children: SortNode[] };
384 & {
385 /** unique identifier. once {@linkcode Node} is disposed, the key is reused. */
386 key: number;
387 logs: readonly log.Message[];
388 /** traverse down the tree. */
389 children: readonly ReadOnlyNode[];
390 /** traverse up the tree. */
391 parent: ReadOnlyNode | null;
392 }
393>;
104394
105export interface Internal extends SortNode {
106 logs: string[];
395/** internal state is read-write. */
396interface Internal extends ts.Writeable<ReadOnlyNode> {
397 logs: log.Message[];
107398 children: Internal[];
108399 parent: Internal | null;
109 signal(msg: Signal): void;
400 detached: boolean;
110401}
111402
112type Signal =
113 | "draw"
114 | { type: "end"; title: string; logs: string[]; state: Internal }
115 | { type: "line"; line: string };
403// TypeScript Test
404void function (unknown: unknown) {
405 (unknown as Internal) satisfies ReadOnlyNode;
406};
116407
117function newNode(
408/**
409 * constructs a linked `Node` and `Internal` that dispatches events to `owner`.
410 * the `Internal` is the non-reactive source of truth that can be blindly cast
411 * to `ReadOnlyNode`, and `Node` is the Node.
412 */
413function newNode<Result, EventMap extends Events.Map>(
414 owner: Root<Result, EventMap>,
118415 text: string,
119416 opts: StartOptions = {},
120417): [Internal, Node] {
418 let estimator: EstimationAlgorithm | null = null;
419
121420 const state: Internal = {
421 key: globalKeyPool.get(),
122422 text,
123423 value: opts.value ?? 0,
124 estimate: opts.estimate ?? 0,
125 showEstimate: opts.showEstimate ?? true,
424 total: opts.total ?? 0,
425 estimatedTime: null,
426 showTotal: opts.showTotal ?? true,
126427 passive: opts.passive ?? false,
127428 hidden: opts.hidden ?? false,
128429 logs: [],
129430 children: [],
130431 parent: null,
131432 sortChildren: opts.sortChildren ?? null,
132 signal() {
133 throw new Error("no signaler");
134 },
433 valueFormatter: opts.valueFormatter ?? {
434 count: defaultValueFormatter,
435 bytes: bytesValueFormatter,
436 percent: percentValueFormatter,
437 }[opts.units ?? "count"],
438 detached: false,
135439 };
136 let detached = false;
137 function end() {
138 if (detached) return;
139 let title = state.text;
140 if (state.parent) {
141 const i = state.parent.children.indexOf(state);
142 ASSERT(i !== -1);
143 let p: Internal | null = state;
144 while (p = p.parent) title = p.text + " / " + title;
145 state.parent.children.splice(i, 1);
146 state.parent = null;
147 detached = true;
148 }
149 state.signal({ type: "end", title, logs: state.logs, state });
150 state.logs = [];
440
441 if (opts.estimateCompletion !== false) {
442 const { estimateCompletion } = opts;
443 if (estimateCompletion && typeof estimateCompletion === "object") {
444 estimator = estimateCompletion;
445 } else estimator = new Ema();
151446 }
152 function mutate() {
447 estimator?.sample(Date.now(), 0);
448
449 function mutate(key: keyof ReadOnlyNode) {
450 if (state.detached) return;
451 if (
452 key === "value" && estimator && state.total > 0 && state.value > 0 &&
453 state.value < state.total
454 ) {
455 const time = estimator.sample(owner.now(), state.value / state.total);
456 if (
457 time && (!state.estimatedTime || state.estimatedTime !== time)
458 ) {
459 state.estimatedTime = time;
460 owner.emit("node-change", state, "estimatedTime");
461 }
462 }
153463 const { parent } = state;
154464 parent?.sortChildren && parent.children.sort(parent.sortChildren);
155 state.signal("draw");
465 owner.emit("node-change", state, key);
156466 }
157 // const scope = log.headlessScope((msg) => {
158 // console.log("TODO");
159 // });
467
468 const scope = log.headlessScope((m) => {
469 if (state.detached) return owner.emit("node-detached-log", m);
470 state.logs.push(m);
471 owner.emit("node-change", state, "logs");
472 });
473
160474 const binding: Node = {
161475 start(text, opts) {
162 const [child, node] = newNode(text, opts);
476 if (state.detached) return nullNode;
477 const [child, node] = newNode(owner, text, opts);
163478 state.children.push(child);
164 state.sortChildren && state.children.sort(state.sortChildren);
165479 child.parent = state;
166 child.signal = state.signal;
167 state.signal("draw");
480 state.sortChildren && state.children.sort(state.sortChildren);
481 owner.emit("node-start", child, state);
482 owner.emit("node-change", state, "children");
168483 return node;
169484 },
170485 get text() {
......@@ -172,135 +487,186 @@ function newNode(
172487 },
173488 set text(value) {
174489 state.text = value;
175 mutate();
490 mutate("text");
176491 },
177492 get value() {
178493 return state.value;
179494 },
180495 set value(value) {
181496 state.value = value;
182 mutate();
497 mutate("value");
183498 },
184 get estimate() {
185 return state.estimate;
499 get total() {
500 return state.total;
186501 },
187 set estimate(value) {
188 state.estimate = value;
189 mutate();
502 set total(value) {
503 state.total = value;
504 mutate("total");
190505 },
191 get showEstimate() {
192 return state.showEstimate;
506 get showTotal() {
507 return state.showTotal;
193508 },
194 set showEstimate(value) {
195 state.showEstimate = value;
196 mutate();
509 set showTotal(value) {
510 state.showTotal = value;
511 mutate("showTotal");
197512 },
198513 get passive() {
199514 return state.passive;
200515 },
201516 set passive(value) {
202517 state.passive = value;
203 mutate();
518 mutate("passive");
204519 },
205520 get hidden() {
206521 return state.hidden;
207522 },
208523 set hidden(value) {
209524 state.hidden = value;
210 mutate();
525 mutate("hidden");
211526 },
212 maxHeight: null,
213 // get maxHeight() {
214 // return state.maxHeight;
215 // },
216 // set maxHeight(value) {
217 // state.maxHeight = value;
218 // mutate();
219 // },
220527 get sortChildren() {
221528 return state.sortChildren;
222529 },
223530 set sortChildren(value) {
224531 state.sortChildren = value;
225 value && state.children.sort(value);
226 state.signal("draw");
532 mutate("sortChildren");
533 },
534 get estimatedTime() {
535 return state.estimatedTime;
536 },
537 set estimatedTime(date) {
538 if (!estimator) {
539 state.estimatedTime = date;
540 mutate("estimatedTime");
541 }
542 },
543 get valueFormatter() {
544 return state.valueFormatter;
545 },
546 set valueFormatter(value) {
547 state.valueFormatter = value;
548 mutate("valueFormatter");
227549 },
228550 inc(delta = 1) {
229551 state.value += delta;
230 if (state.estimate > 0 && state.value >= state.estimate) {
231 end();
552 if (state.total > 0 && state.value >= state.total) {
553 endNode(owner, state);
232554 } else {
233 mutate();
555 mutate("value");
234556 }
235557 },
236 log: log.scoped(""),
237 end: () => void end(),
238 [Symbol.dispose]: () => void end(),
558 log: scope,
559 end: () => void endNode(owner, state),
560 [Symbol.dispose]: () => void endNode(owner, state),
239561 };
240562 return [state, binding];
241563}
242564
243function formatProgress(now: number, list: Internal[]): string {
565const noop = () => {};
566noop[Symbol.dispose] = noop;
567
568/** minimal implementation of {@linkcode Node} that has no I/O */
569export const nullNode: Node = {
570 start: () => nullNode,
571 get text() {
572 return "[detached]";
573 },
574 set text(_) {
575 },
576 get value() {
577 return 0;
578 },
579 set value(_) {
580 },
581 get total() {
582 return 0;
583 },
584 set total(_) {
585 },
586 inc: noop,
587 log: {
588 info: noop,
589 warn: noop,
590 error: noop,
591 log: noop,
592 debug: noop,
593 scoped() {
594 return this;
595 },
596 tee: () => noop,
597 },
598 end: () => {},
599 [Symbol.dispose]: () => {},
600};
601
602function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {
603 if (state.detached) return;
604 state.children.forEach((child) => endNode(owner, child));
605 state.detached = true;
606 globalKeyPool.recycle(state.key);
607 let title = state.text;
608 if (state.parent) {
609 const i = state.parent.children.indexOf(state);
610 ASSERT(i !== -1);
611 let p: Internal | null = state;
612 while (p = p.parent) title = p.text + " / " + title;
613 state.parent.children.splice(i, 1);
614 }
615 owner.emit("node-end", state);
616 state.logs = [];
617 state.parent = null;
618}
619
620/** Convert the top level `ReadOnlyNode[]` into ANSI text. */
621export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string {
244622 let out = "";
245623 for (const top of list) {
246624 if (top.passive && !hasChildren(top)) continue;
247 out += renderMainLine(top, now, 0) + "\n";
625 out += renderAnsiMainLine(top, now, 0) + "\n";
248626 out += renderChildren(top, now, []);
249627 }
250628 return out.trimEnd();
251629}
252630
253/** These functions are used to test `lib/progress` */
254export namespace internals {
255 export function node(
256 text: string,
257 props: Partial<Internal>,
258 children: Internal[],
259 ): Internal {
260 const [node] = newNode(text);
261 Object.assign(node, props);
262 node.children.push(...children);
263 return node;
264 }
265 export const format = formatProgress;
266}
267
268631const spinnerFps = 12.5;
269
270const box = {
271 tee: "├─ ",
272 line: "│ ",
273 langle: "└─ ",
274};
632const box = { tee: "├─ ", line: "│ ", langle: "└─ " };
275633const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
276634const fullBar = "█";
277635const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
278636 .map((frame) => ansi.style(ansi.fgBlue, frame));
279637
280function renderMainLine(state: Internal, now: number, depth: number) {
281 if (state.estimate && state.showEstimate) {
638function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
639 const { text, total, showTotal, value, estimatedTime } = state;
640 const dateNow = Date.now();
641 const estimate = estimatedTime && (estimatedTime > dateNow + 1000)
642 ? ", " +
643 string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000))
644 : "";
645 const valueFormatted = state.valueFormatter(value, total);
646 if (total && showTotal) {
282647 return ansi.style(
283648 ansi.bgBrightBlack + ansi.fgBlue,
284 getUnicodeBar(
285 state.value / state.estimate,
649 formatUnicodeBar(
650 value / total,
286651 Math.max(4, depth > 0 ? 12 : 25),
287652 ),
288653 ) +
289 ` [${state.value}/${state.estimate}] ` + state.text;
654 (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ") +
655 text;
290656 }
291657 const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length;
292 return (depth === 0 ? spinner[frame] + " " : "") +
293 (state.value > 0 ? `[${state.value}] ` : "") + state.text;
658 return ((depth === 0 ? spinner[frame] + " " : "") +
659 (valueFormatted ? `[${valueFormatted}] ` : "") + text);
294660}
295661
296function hasChildren(states: Internal): boolean {
662function hasChildren(states: ReadOnlyNode): boolean {
297663 return states.children.some((x) =>
298664 !x.hidden && (!x.passive || hasChildren(x))
299665 );
300666}
301667
302function renderChildren(state: Internal, now: number, depth: boolean[]) {
303 let maxHeight = 50; // TODO:
668function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
669 let maxHeight = 50; // TODO: flexible layout
304670 let truncated = 0;
305671 let out = "";
306672 let { children } = state;
......@@ -313,7 +679,7 @@ function renderChildren(state: Internal, now: number, depth: boolean[]) {
313679 let item = "";
314680 const left = depth.map((x) => x ? box.line : " ").join("");
315681 item += left + (i === length - 1 && !truncated ? box.langle : box.tee);
316 item += renderMainLine(child, now, 1 + depth.length) + "\n";
682 item += renderAnsiMainLine(child, now, 1 + depth.length) + "\n";
317683 item += renderChildren(child, now, depth.concat(i < length - 1));
318684 const h = string.countNewlines(item);
319685 if (h > maxHeight) {
......@@ -323,7 +689,7 @@ function renderChildren(state: Internal, now: number, depth: boolean[]) {
323689 const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? [];
324690 for (const line of logLines) {
325691 item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") +
326 " " + line + "\n";
692 " " + log.formatMessage(line, true) + "\n";
327693 }
328694 maxHeight -= h + logLines.length;
329695 out += item;
......@@ -340,7 +706,7 @@ function renderChildren(state: Internal, now: number, depth: boolean[]) {
340706 * which did ffmpeg handling. It is probably one of the coolest progress bars
341707 * ever imagined.
342708 */
343export function getUnicodeBar(progress: number, width: number): string {
709export function formatUnicodeBar(progress: number, width: number): string {
344710 if (progress >= 1) return fullBar.repeat(width);
345711 if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width);
346712
......@@ -356,58 +722,143 @@ export function getUnicodeBar(progress: number, width: number): string {
356722 return fill + partChar + empty;
357723}
358724
359/** Run a progress tree without a dependency on a TTY */
360export class Headless {
361 constructor(
362 /**
363 * `startWidget` is only ever called from `start`
364 * `writeLine` is used for logs from completed nodes
365 */
366 public env: Pick<headless.WidgetHost, "writeLine" | "startWidget">,
367 ) {}
725/**
726 * configure a `Root` to display its contents to a `log.HeadlessWidgetHost`.
727 * this is used by the global progress instance to output to the terminal
728 * ```ts
729 * const globalProgress = new progress.Root();
730 * progress.attachToScreen(log);
731 * ```
732 * unit tests use a mock screen instead of the terminal.
733 * ```ts
734 * const screen = new TestWidgetHost();
735 * const root = new progress.Root(screen.delay);
736 * root.attachToScreen(screen);
737 * // can use the progress API
738 * root.start("hello");
739 * // and then examine the contents
740 * screen.expectFrame(...);
741 * ```
742 */
743export function attachToScreen(
744 root: Root,
745 { writeLine, startWidget }: Pick<
746 log.HeadlessWidgetHost,
747 "writeLine" | "startWidget"
748 >,
749): ts.Dispose {
750 const stack = new DisposableStack();
368751
369 active: Internal[] = [];
752 let rerender: (() => void) | null = null;
753 const widget: log.Widget = {
754 format: (now) => root.active ? formatAnsi(now, root.active) : null,
755 onChange: (cb) => (rerender = cb, () => rerender = null),
756 fps: spinnerFps,
757 };
370758
371 start(text: string, opts?: StartOptions): Node {
372 const [state, node] = newNode(text, opts);
373 this.active.push(state);
374 state.signal = this.#signal;
375 const rerender = this.#rerender;
759 stack.use(root.on("change", (items) => {
376760 if (rerender) rerender();
377 else log.startWidget(this.widget);
378 return node;
761 else if (items.length > 0) startWidget(widget);
762 }));
763 stack.use(
764 root.on(
765 "node-detached-log",
766 (msg) => writeLine(log.formatMessage(msg, true)),
767 ),
768 );
769 stack.use(root.on("node-end", (node) => {
770 let title = node.text;
771 let p: ReadOnlyNode | null = node;
772 while (p = p.parent) title = p.text + " / " + title;
773 const { logs } = node;
774 if (logs.length > 0) {
775 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
776 const header = `[${count} from ${title}]`;
777 writeLine(ansi.style(ansi.fgBrightBlack, header));
778 writeLine(logs.map((msg) => log.formatMessage(msg, true)).join("\n"));
779 }
780 }));
781
782 return ts.defer(() => stack[Symbol.dispose]);
783}
784
785class KeyPool<T extends number> {
786 next = 1 as T;
787 old: T[] = [];
788
789 get() {
790 const existing = this.old.shift();
791 if (existing) return existing as T;
792 const next = this.next = this.next + 1 as T;
793 return next as T;
379794 }
380795
381 widget: log.Widget = {
382 format: (now) => formatProgress(now, this.active),
383 onChange: (
384 rerender,
385 ) => (this.#rerender = rerender, () => this.#rerender = null),
386 };
796 recycle(k: T) {
797 this.old.push(k);
798 }
799}
387800
388 #rerender?: (() => void) | null;
389 #signal = (msg: Signal) => {
390 if (msg === "draw") this.#rerender?.();
391 else if (msg.type === "line") this.env.writeLine(msg.line);
392 else if (msg.type === "end") {
393 const indexOfActive = this.active.indexOf(msg.state);
394 if (indexOfActive !== -1) this.active.splice(indexOfActive, 1);
395 if (msg.logs.length === 0) {
396 this.#rerender?.();
397 return;
398 }
399 const { logs } = msg;
400 const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`;
401 const header = `[${count} from ${msg.title}]`;
402 this.env.writeLine(ansi.style(ansi.fgBrightBlack, header));
403 this.env.writeLine(msg.logs.join("\n"));
801/** stateful estimation by providing samples */
802export interface EstimationAlgorithm {
803 /**
804 * given a timestamp `time` and a progress value `progress`, return the
805 * timestamp that the action will be finished on.
806 */
807 sample(time: number, progress: number): number | null;
808}
809
810/**
811 * Exponential Moving Average.
812 * this is the algorithm used for the default progress estimator.
813 * https://en.wikipedia.org/wiki/Exponential_smoothing
814 */
815export class Ema implements EstimationAlgorithm {
816 start: number | null = null;
817 estimate: number | null = null;
818 /** smoothing factor (0-1) */
819 alpha: number = 0.25;
820
821 constructor(alpha: number = 0.1) {
822 this.alpha = alpha;
823 }
824
825 /**
826 * given a timestamp `time` and a progress value `progress`, return the
827 * timestamp that the action will be finished on.
828 */
829 sample(time: number, progress: number): number | null {
830 ASSERT(
831 progress >= 0 && progress < 1,
832 `progress must be a number between 0 and 1, got ${progress}`,
833 );
834 if (this.start == null) {
835 this.start = time;
836 return null;
404837 }
405 };
838
839 const value = (time - this.start) / progress;
840 this.estimate = this.estimate != null
841 ? ((1 - this.alpha) * this.estimate +
842 this.alpha * value)
843 : value;
844
845 return time + (1 - progress) * this.estimate;
846 }
847
848 reset(): void {
849 this.start = null;
850 this.estimate = null;
851 }
406852}
407853
408const global = /** @__PURE__ */ new Headless(log);
854const globalKeyPool = new KeyPool<number>();
855const global =
856 /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))();
409857
858import * as async from "./async.ts";
410859import * as log from "./log.ts";
411860import * as ansi from "./string/ansi.ts";
861import * as ts from "./ts.ts";
412862import * as string from "./string.ts";
413863import { ASSERT, UNWRAP } from "./assert.ts";
864import { Events } from "./Events.ts";
lib/string.ts+49
......@@ -32,3 +32,52 @@ export function escapeShellArgument(s: string): string {
3232 if (s.startsWith("'")) complex = complex.slice(2);
3333 return complex;
3434}
35
36const te = new TextEncoder();
37export function encodeUtf8(input: string): Uint8Array<ArrayBuffer> {
38 return te.encode(input) as Uint8Array<ArrayBuffer>;
39}
40
41const td = new TextDecoder();
42export function decodeUtf8(input: Uint8Array): string {
43 return td.decode(input);
44}
45
46const byteUnits = "kMGTPEZYRQ";
47export function formatByteSize(bytes: number) {
48 if (!Number.isFinite(bytes)) return bytes.toString();
49 let prefix = "";
50 if (bytes < 0) bytes = -bytes, prefix = "-";
51 if (bytes < 1000) return (Math.round(bytes * 100) / 100) + "B";
52 let unit = 0;
53 do {
54 bytes /= 1000;
55 unit += 1;
56 } while (bytes >= 1000);
57 return prefix +
58 bytes.toFixed(
59 Math.floor(bytes) === bytes || unit === 1 ? 0 : bytes > 100 ? 1 : 2,
60 ) +
61 byteUnits.charAt(unit - 1) + "B";
62}
63
64export function formatBinaryByteSize(bytes: number) {
65 let prefix = "";
66 if (bytes < 0) bytes = -bytes, prefix = "-";
67 if (bytes < 1024) return (Math.round(bytes * 100) / 100) + "B";
68 let unit = 0;
69 do {
70 bytes /= 1024;
71 unit += 1;
72 } while (bytes >= 1024);
73 return prefix + (Math.round(bytes * 100) / 100) +
74 byteUnits.charAt(unit - 1) + "iB";
75}
76
77// TODO:
78export function formatDurationLetters(seconds: number) {
79 const minutes = Math.floor(seconds / 60);
80 const remainingSeconds = seconds % 60;
81 if (minutes < 1) return `${remainingSeconds}s`;
82 return `${minutes}m${remainingSeconds}s`;
83}