From db344d63f2e3db38db746bc34d259e6ff5af1e87 Mon Sep 17 00:00:00 2001 From: clover caruso Date: Wed, 15 Oct 2025 23:36:01 -0700 Subject: [PATCH] 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` --- lib/progress.sample.ts | 73 ---- lib/progress.test.ts | 102 +++++ lib/progress.ts | 861 +++++++++++++++++++++++++++++++---------- lib/string.ts | 49 +++ 4 files changed, 807 insertions(+), 278 deletions(-) delete mode 100644 lib/progress.sample.ts create mode 100644 lib/progress.test.ts diff --git a/lib/progress.sample.ts b/lib/progress.sample.ts deleted file mode 100644 index efc12ce0005393cbda11b7e41aea148fefb6dae6..0000000000000000000000000000000000000000 --- a/lib/progress.sample.ts +++ /dev/null @@ -1,73 +0,0 @@ -const root = progress.start("progress node", { estimate: 30 }); -for (let i = 0; i < 10; i += 1) { - { - const subNodes = Array.from( - { length: 4 }, - (_) => root.start("subtask " + random()), - ); - await delay(); - subNodes[0]!.end(); - const n = subNodes[1]!.start("meowing", { estimate: 7 }); - const m = subNodes[1]!.start("purring", { estimate: 7 }); - let w: progress.Node | null = null; - let x: progress.Node | null = null; - let y: progress.Node | null = null; - let z: progress.Node | null = null; - for (let i = 0; i < 14; i += 1) { - if (Math.random() > 0.5 && n.value < n.estimate) { - n.inc(); - } else { - m.inc(); - } - await short(); - if (i === 5) subNodes[3]!.end(); - if (i === 3) { - x = subNodes[2]!.start("other task"); - y = x.start("deeply nested"); - z = y.start("job"); - z.log.info("with inline logs"); - z.log.warn("with inline logs"); - } - if (i == 8) z?.end(); - if (i === 6) w = y!.start("magic"); - if (i == 12) y!.end(); - if (i === 10) subNodes[3]!.end(); - } - await delay(); - n.inc(); - await delay(); - n.end(); - root.inc(); - subNodes.forEach((x) => x.end()); - } - { - const subNodes = Array.from( - { length: 8 }, - (_) => root.start("subtask with count " + random()), - ); - subNodes.forEach((x) => x.value = 1); - await delay(); - subNodes[0]!.end(); - for (let i = 0; i < 80; i += 1) { - const node = subNodes[Math.floor(Math.random() * 8)]!; - node.inc(); - if (node.value > 10) node.end(); - await short(); - } - await delay(); - subNodes.forEach((x) => x.end()); - } -} - -async function delay() { - await timers.setTimeout(Math.random() * 100 + 200); -} -async function short() { - await timers.setTimeout(Math.random() * 50 + 50); -} -function random() { - return Math.floor(Math.random() * 9999999).toString(32); -} - -import * as progress from "lib/progress.ts"; -import * as timers from "node:timers/promises"; diff --git a/lib/progress.test.ts b/lib/progress.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2b9a3e34ea1e532c757f22debd88f3492d575371 --- /dev/null +++ b/lib/progress.test.ts @@ -0,0 +1,102 @@ +// a trivial example of how to use progress +test("trivial end-to-end example", () => { + const { fgBlue: FB, fgReset: FR, reset: R } = ansi; + const screen = new testing.MockScreen(); // create a mock terminal + const root = new progress.Root(screen); // sync with mock timers + progress.attachToScreen(root, screen); // attach `root` to the `screen` + assert.equal(screen.waitCalls.length, 0); // attaching does not render + + const a = root.start("hello"); + const b = root.start("cats"); + const _ = b.start("meow"); + + assert.equal(screen.waitCalls.length, 1); + + screen.expectFrame(1, { merged: "" }); // debounce + // TODO: why the full resets? + screen.expectFrame(0, { + merged: testing.MockScreen.sync([ + `\r${FB}⠋${FR} hello${R}\n`, + `${FB}⠋${FR} cats${R}\n`, + "└─ meow\n", + ]), + }); + + screen.expectFrame(80, { + merged: testing.MockScreen.sync([ + `\r` + ansi.cursorUp(3), + `${FB}⠙${FR} hello${R}\n`, + `${FB}⠙${FR} cats${R}\n`, + "└─ meow\n", + ]), + }); + + a.end(); + b.end(); + + screen.expectFrame(80, { + merged: testing.MockScreen.sync([ + `\r` + ansi.cursorUp(1) + ansi.clearFullLine, + ansi.cursorUp(1) + ansi.clearFullLine, + ansi.cursorUp(1) + ansi.clearFullLine, + ]), + }); +}); + +// ## value formatters +test("slashValueFormatter", ({ mock }) => { + const fn = mock.fn((arg: number) => `<${arg}>`); + + const fmt = progress.slashValueFormatter(fn); + assert.equal(fmt(250, 0), "<250>"); + assert.equal(fn.mock.callCount(), 1); + assert.equal(fmt(250, 450), "<250>/<450>"); + assert.equal(fn.mock.callCount(), 3); +}); +test("bytesValueFormatter", () => { + assert.equal(progress.bytesValueFormatter(250, 0), "250B"); + assert.equal(progress.bytesValueFormatter(0, 1_200_000), "0B/1.2MB"); +}); +test("defaultValueFormatter", () => { + assert.equal(progress.defaultValueFormatter(250, 0), "250"); + assert.equal(progress.defaultValueFormatter(0, 1_200_000), "0/1200000"); +}); + +test("percentValueFormatter", () => { + assert.equal(progress.percentValueFormatter(100, 200), "50%"); + assert.equal(progress.percentValueFormatter(400, 400), "100%"); + assert.equal(progress.percentValueFormatter(0, 1_200_000), "0%"); + assert.equal(progress.percentValueFormatter(0, null), "0%"); + assert.equal(progress.percentValueFormatter(0.5, null), "50%"); +}); + +// ## `progress.Ema` +// +// the exponential moving average algorithm can be used on its own +// +test("Ema", () => { + // first sample always returns null, if the progress moves linearly then the + // estimated time will be the same. + let ema = new progress.Ema(); + assert.equal(ema.sample(50_000, 0), null); + assert.equal(ema.sample(50_010, 0.1), 50_100); + assert.equal(ema.sample(50_020, 0.2), 50_100); + assert.equal(ema.sample(50_080, 0.8), 50_100); + + // this algorithm estimates somewhat well + ema = new progress.Ema(); + assert.equal(ema.sample(50_000, 0), null); + assert.equal(ema.sample(50_010, 0.1), 50_100); + assert.equal(ema.sample(50_020, 0.3), 50_087.666666666664); + assert.equal(ema.sample(50_050, 0.4), 50_109.7); + assert.equal(ema.sample(50_100, 0.6), 50_142.486666666664); + assert.equal(ema.sample(50_110, 0.7), 50_143.392785714284); + assert.equal(ema.sample(50_115, 0.8), 50_137.91067142857); + assert.equal(ema.sample(50_130, 0.9), 50_141.754246587305); +}); + +import * as progress from "./progress.ts"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import * as testing from "./testing.ts"; +import * as ansi from "./string/ansi.ts"; diff --git a/lib/progress.ts b/lib/progress.ts index 49f145f399969a046f5702be48995915168aa3d0..bca5009a630021e7a9ceb6f85a95401f55ab14ba 100644 --- a/lib/progress.ts +++ b/lib/progress.ts @@ -1,29 +1,83 @@ -// For those coming from my blog, check out this video -// -> https://paperclover.net/file/2025/file%20scanner.mp4?view=embed -// -// There is some massive rework pending on this file, check out the PR -// -> https://git.paperclover.net/clo/sitegen/pulls/48 -// -// This file is under heavy construction. -// - I am happy with the overall design -// - Will rename things -// - Will be re-working the entire module to have a headless -// core, but still leaving the top level `start` function. -// - There are an overwhelming amount of bugs -// -// a progress tree based on my past uses with clover console v3 and the Zig -// `Progress` API. Pass `Ref` to functions which should track progress, calling -// `.start()` to create child items. Or, multiple top-level progress bars can -// be created with the singleton's `start()` function. +/** + * a progress reporting API that allows creating CLI spinners and progress bars + * that can be easily nested to visualize complex, parallel work. in addition + * to providing rich feedback in the terminal, a headless {@linkcode Root} node + * can be constructed, which can be used to bind to {@link formatHtml|the DOM} + * or transmit progress information {@link encodeStream|across a network}. + * + * the progress API takes advantage of the `using` keyword to make nodes + * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref}, + * to act as the destination for the sub-node. + * + * ```ts + * async function doSomething(p: progress.Ref) { + * using node = p.start("my task"); + * // ... + * await doSomethingElse(node); // node satisfies progress.Ref + * // ... + * } + * async function doSomethingElse(p: progress.Ref) { + * using node = p.start("my sub task"); + * // ... + * } + * + * doSomething(progress); // the progress module satisfies progress.Ref + * + * import * as progress from "@clo/lib/progress.ts"; + * ``` + * + * in some cases, such as in `subprocess/ffmpeg.ts`, it may make more sense + * to pass in a pre-started node. the `ffmpeg` module, for example, uses the + * starting text as a base title and decorates it with encoding stats. + * + * when using the top-level {@linkcode start|progress.start}, proper + * integration is made with `lib/log.ts` so that the progress tree does not + * intersect with regular log messages, as well as forwarding each + * {@linkcode Node} custom logging scope to the global log, including any + * custom redirections. + * + * this module is under construction. while i am happy with the overall API, it + * needs more work and feature development. + * + * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html). + * @module + */ /** - * creates a new trackable unit of work as a child of this one. - * when given an estimate, a progress bar is rendered. + * creates a new trackable unit of work, visible in the terminal as a spinner + * that rests at the end of the log. if `estimate` is given, a progress bar is + * shown. */ export function start(text: string, opts?: StartOptions): Node { return global.start(text, opts); } +/** + * a reference point to create sub-items. this interface is satisfied by + * - the `progress` module itself + * - a node recieved by `p.start("label")` + * - a headless progress runner from `progress.headless()` + * + * prefer taking `Ref` as a function parameter, so that the function can be + * started at the top level, or as a sub-task of an existing node. + * + * ```ts + * function build(p: progress.Ref = progress) { + * using _ = p.start("build process"); + * // ... + * } + * ``` + * + * sometimes it makes sense to have an API recieve `Node` instead of `Ref`, + * specifically if the caller should have some control over the node name. + * + * ```ts + * await ffmpeg.spawn({ + * cmd: ["-i", "hello.mov", "-c:v", "libsvtav1", "hello.mp4"], + * progress: progress.start("encode hello.mov"), + * }); + * ``` + */ export interface Ref { /** * creates a new trackable unit of work as a child of this one. @@ -32,139 +86,400 @@ export interface Ref { start(text: string, opts?: StartOptions): Node; } +/** + * control for a progress nooe. all fields are primed with setters to trigger + * ui dispatch automatically. to make tools that use `progress.ts` more + * modular to a custom progress implementation, many fields are optional. + */ export interface Node extends Ref, Disposable { - /** reactive */ + /** end this Node or RootNode. ending collapses children. */ + end(this: Node): void; + /** one line of text display. */ text: string; - /** reactive */ + /** + * number of items completed. by default this is a count of items, but the + * `units` property can be passed to `start` to change how the `value` is + * interpretted when displaying this progress node. + */ value: number; - /** reactive */ - estimate: number; /** - * reactive, default `true`. `false` will hides a bar + printing estimate, - * but preseving auto-end behavior of `inc` + * an estimate of how many items this task contains. + * when set above `0`, it is shown with a progress bar. */ - showEstimate: boolean; + total: number; /** - * reactive, default `false`. when true, this progress item is hidden if + * defaults to increasing by 1. if incrementing causes `value >= estimate`, + * it also calls `end`. use `node.value += 1` if that behavior is not wanted. + */ + inc(this: Node, value?: number): void; + /** a scoped logger set to output inline on this item. */ + readonly log: log.Scope; + + /** + * a time for when this node is estimated to be completed, in milliseconds + * relative to the unix epoch. by default, this is automatically managed and + * made read-only. if `StartOptions` specifies `estimateCompletions: false`, + * this field may be mutated. + */ + estimatedTime?: number | null; + /** + * default `true`. `false` will hides a bar + printing total. this is useful + * for preseving auto-end behavior of `inc` without showing the total. + */ + showTotal?: boolean; + /** + * default `false`. when true, this progress item is hidden if * there are no children. */ - passive: boolean; + passive?: boolean; + /** default `false`. when true, this progress item is hidden */ + hidden?: boolean; + /** override sorting for children */ + sortChildren?: ((a: ReadOnlyNode, b: ReadOnlyNode) => number) | null; /** - * reactive, default `false`. when true, this progress item is hidden + * specify how `value` and `total` is formed into text. + * this has some discoverable presets on `StartOptions.units`. */ - hidden: boolean; - /** reactive. null means decide based on nested depth + terminal height */ - maxHeight: null | number; - /** reactive. specify sorting for children */ - sortChildren: ((a: SortNode, b: SortNode) => number) | null; - - /** defaults to increasing by 1. hitting estimate calls end */ - inc(value?: number): void; - - /** A scoped logger set to output inline to this item. */ - log: log.Scope; - - /** end this Node or RootNode. ending collapses children */ - end(): void; + valueFormatter?: (value: number, total: number | null) => string; } +/** override the default field values of {@link Node} on creation. */ export interface StartOptions { + /** number of items already completed. */ value?: number | undefined | null; - estimate?: number | undefined | null; + /** when set above `0`, it is shown with a progress bar. */ + total?: number | undefined | null; + /** + * presets for common formattings of `value` and `total`. + * @default "count" + */ + units?: "count" | "bytes" | "percent"; + /** + * customize how an estimate is given for progress bars. to make + * `Node.estimatedTime` mutable, pass `false` here. + * @default `null`, which is equivilent to `new progress.Ema()` + */ + estimateCompletion?: EstimationAlgorithm | boolean | null; + /** + * when true, this progress item is hidden if there are no children. + * this makes sense when scheduling items with a priority queue, where a + * passive node is only used for grouping, but children are used for + * indicating status. + * @default false + */ passive?: boolean | undefined | null; + /** default `false`. when true, this progress item is hidden */ hidden?: boolean | undefined | null; - showEstimate?: boolean | undefined | null; - maxHeight?: number | undefined | null; + /** + * default `true`. `false` will hides a bar + printing estimate, + * but preseving auto-end behavior of `inc` + */ + showTotal?: boolean | undefined | null; + /** specify sorting for children */ sortChildren?: Node["sortChildren"] | undefined | null; + /** + * specify how `value` and `total` is formed into text. + * this has some discoverable presets on `StartOptions.units`. + */ + valueFormatter?: (value: number, total: number | null) => string; + /** pre-fill the list of log messages */ + messages?: log.Message[]; } -/** a valid `Node` without any output */ -const nullNode: Node = /* @__PURE__ */ - (function start(text: string, opts?: StartOptions): Node { - const node = newNode(text, opts)[1]; - node.start = start; - node.log = log.scoped(""); +/** + * creates a function to use as a `Node.valueFormatter` by using a single + * number formatter, and then joining the node's value and total with a "/", + * but leaving it out if there is no total. + */ +export function slashValueFormatter(fmtNumber: (value: number) => string) { + return (value: number, total: number | null) => + value > 0 + ? fmtNumber(value) + + (total != null && total > 0 ? "/" + fmtNumber(total) : "") + : ""; +} + +/** the formatter used when passing `units: "count"` to {@linkcode start} */ +export const defaultValueFormatter = /* @__PURE__ */ + slashValueFormatter(String); + +/** the formatter used when passing `units: "bytes"` to {@linkcode start} */ +export const bytesValueFormatter = /* @__PURE__ */ + slashValueFormatter(string.formatByteSize); + +/** the formatter used when passing `units: "percent"` to {@linkcode start} */ +export function percentValueFormatter(value: number, total: number | null) { + if (total != null && total > 0) { + value /= total; + } + return `${Math.round(value * 1000) / 10}%`; +} + +/** + * {@linkcode Root} allows overriding it's timing functions, which are used for + * mostly for rendering and streaming adapters. + */ +export interface RootOptions { + delay?: typeof async.delay; + /** used for debouncing and UI. time estimation does NOT use this method */ + now?: typeof performance.now; +} + +/** + * a headless progress node. when the state of the tree changes, the `change` + * event is emitted (batched with many changes). the primary use case of using + * a custom `Root`s is to stream a task's progress over a network or process + * IPC using {@linkcode encodeByteStream}/{@linkcode encodeEventStream}. + * another use case is to render a progress task differently. + * + * custom events may be dispatched on the `Root`, but this is only useful when + * streaming, since the streams will carry any custom events. + */ +export class Root< + Result = void, + Map extends Events.Map = ts.EmptyObject, +> extends Events> { + delay: typeof async.delay; + now: typeof performance.now; + #status: "active" | "end" | "error" = "active"; + #active: Internal[] = []; + #debounce: async.Cancelable | null = null; + + constructor({ delay, now }: RootOptions = {}) { + super(); + this.delay = delay ?? async.delay; + this.now = now ?? performance.now.bind(performance); + this.on("node-end", (state) => { + if (state.parent) return; + const i = this.#active.indexOf(state as Internal); + ASSERT(i !== -1); + this.#active.splice(i, 1); + }); + } + + /** the caller may not mutate `Root`'s state */ + get active(): readonly ReadOnlyNode[] { + return this.#active; + } + + /** + * creates a new trackable unit of work at the top level of this root. + * when given an estimate, a progress bar is rendered. + */ + start(text: string, opts?: StartOptions): Node { + const [state, node] = newNode(this, text, opts); + this.#active.push(state); + this.emit("node-start", state, null); return node; - })("root"); + } -export { nullNode as null }; + /** + * end this root, as well as all children nodes. this emits the `end` event + * with the provided value, JSON-serializing it if connected via + * `encodeEventStream` or `encodeByteStream`. + * + * this function is pre-bound so that it can be passed to `then`: + * ```ts + * const root = new Root(); + * void asyncFunction().then(root.end, root.error); + * ``` + */ + end = (result: Result): void => { + this.emit("end", result); + }; -export type SortNode = + /** + * end this root, as well as all children nodes. this emits the `error` event. + */ + error = (error: unknown): void => { + this.emit("error", error); + }; + + /** convert this root into a promise. */ + asPromise(): Promise { + return this.once("end").then((x) => x[0]); + } + + /** + * emit an event on the specified channel. behavior is not defined when manually + * emitting one of `progress`'s built in events. + */ + override emit>( + channel: C, + ...args: MergeRootEvents[C] + ): void { + ASSERT(this.#status === "active"); + if (channel !== "change" && rootEvents.has(channel)) this.#emitChangeSoon(); + if (channel === "error" || channel === "end") { + this.#end(); + this.#status = channel; + } + super.emit(channel, ...args); + } + + #end() { + const hasActive = this.#active.length > 0 || !this.#debounce; + for (const active of this.#active) endNode(this, active); + this.#active.splice(0, this.#active.length); + this.#debounce?.cancel(); + if (hasActive) this.emit("change", this.#active); + } + + #emitChangeSoon = () => { + if (this.#debounce) return; + (this.#debounce = this.delay(1)) + .then(() => { + this.#debounce = null; + this.emit("change", this.#active); + }); + }; +} + +export type RootEventMap = { + // this event is the debounced "ready to re-render" event. do not emit. + "change": [rootNodes: readonly ReadOnlyNode[]]; + // these events are emitted without a debounce, and are used for general + // communication within `Root`'s implementation. do not emit. + "node-start": [newNode: ReadOnlyNode, parentNode: ReadOnlyNode | null]; + "node-change": [node: ReadOnlyNode, key: keyof ReadOnlyNode]; + "node-end": [node: ReadOnlyNode]; + "node-detached-log": [msg: log.Message]; + // these events are used for result communication. they can be sent via + // `emit` or the shorthand methods `end` and `error`. sending either will put + // the `Root` into a "done" state. + "end": [result: Result]; + "error": [error: unknown]; +}; + +const rootEvents: ReadonlySet = new Set([ + "change", + "node-start", + "node-change", + "node-end", + "node-detached-log", + "end", + "error", +]); + +type MergeRootEvents = { + [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap + ? RootEventMap[K] + : Map[K]; +}; + +/** a read-only version of {@linkcode Node}, provided to renderers. */ +export type ReadOnlyNode = Readonly< & Pick< - Node, + Required, | "text" | "value" - | "estimate" + | "total" + | "estimatedTime" | "passive" | "hidden" - | "showEstimate" + | "showTotal" | "sortChildren" + | "valueFormatter" > - & { children: SortNode[] }; + & { + /** unique identifier. once {@linkcode Node} is disposed, the key is reused. */ + key: number; + logs: readonly log.Message[]; + /** traverse down the tree. */ + children: readonly ReadOnlyNode[]; + /** traverse up the tree. */ + parent: ReadOnlyNode | null; + } +>; -export interface Internal extends SortNode { - logs: string[]; +/** internal state is read-write. */ +interface Internal extends ts.Writeable { + logs: log.Message[]; children: Internal[]; parent: Internal | null; - signal(msg: Signal): void; + detached: boolean; } -type Signal = - | "draw" - | { type: "end"; title: string; logs: string[]; state: Internal } - | { type: "line"; line: string }; +// TypeScript Test +void function (unknown: unknown) { + (unknown as Internal) satisfies ReadOnlyNode; +}; -function newNode( +/** + * constructs a linked `Node` and `Internal` that dispatches events to `owner`. + * the `Internal` is the non-reactive source of truth that can be blindly cast + * to `ReadOnlyNode`, and `Node` is the Node. + */ +function newNode( + owner: Root, text: string, opts: StartOptions = {}, ): [Internal, Node] { + let estimator: EstimationAlgorithm | null = null; + const state: Internal = { + key: globalKeyPool.get(), text, value: opts.value ?? 0, - estimate: opts.estimate ?? 0, - showEstimate: opts.showEstimate ?? true, + total: opts.total ?? 0, + estimatedTime: null, + showTotal: opts.showTotal ?? true, passive: opts.passive ?? false, hidden: opts.hidden ?? false, logs: [], children: [], parent: null, sortChildren: opts.sortChildren ?? null, - signal() { - throw new Error("no signaler"); - }, + valueFormatter: opts.valueFormatter ?? { + count: defaultValueFormatter, + bytes: bytesValueFormatter, + percent: percentValueFormatter, + }[opts.units ?? "count"], + detached: false, }; - let detached = false; - function end() { - if (detached) return; - let title = state.text; - if (state.parent) { - const i = state.parent.children.indexOf(state); - ASSERT(i !== -1); - let p: Internal | null = state; - while (p = p.parent) title = p.text + " / " + title; - state.parent.children.splice(i, 1); - state.parent = null; - detached = true; - } - state.signal({ type: "end", title, logs: state.logs, state }); - state.logs = []; + + if (opts.estimateCompletion !== false) { + const { estimateCompletion } = opts; + if (estimateCompletion && typeof estimateCompletion === "object") { + estimator = estimateCompletion; + } else estimator = new Ema(); } - function mutate() { + estimator?.sample(Date.now(), 0); + + function mutate(key: keyof ReadOnlyNode) { + if (state.detached) return; + if ( + key === "value" && estimator && state.total > 0 && state.value > 0 && + state.value < state.total + ) { + const time = estimator.sample(owner.now(), state.value / state.total); + if ( + time && (!state.estimatedTime || state.estimatedTime !== time) + ) { + state.estimatedTime = time; + owner.emit("node-change", state, "estimatedTime"); + } + } const { parent } = state; parent?.sortChildren && parent.children.sort(parent.sortChildren); - state.signal("draw"); + owner.emit("node-change", state, key); } - // const scope = log.headlessScope((msg) => { - // console.log("TODO"); - // }); + + const scope = log.headlessScope((m) => { + if (state.detached) return owner.emit("node-detached-log", m); + state.logs.push(m); + owner.emit("node-change", state, "logs"); + }); + const binding: Node = { start(text, opts) { - const [child, node] = newNode(text, opts); + if (state.detached) return nullNode; + const [child, node] = newNode(owner, text, opts); state.children.push(child); - state.sortChildren && state.children.sort(state.sortChildren); child.parent = state; - child.signal = state.signal; - state.signal("draw"); + state.sortChildren && state.children.sort(state.sortChildren); + owner.emit("node-start", child, state); + owner.emit("node-change", state, "children"); return node; }, get text() { @@ -172,135 +487,186 @@ function newNode( }, set text(value) { state.text = value; - mutate(); + mutate("text"); }, get value() { return state.value; }, set value(value) { state.value = value; - mutate(); + mutate("value"); }, - get estimate() { - return state.estimate; + get total() { + return state.total; }, - set estimate(value) { - state.estimate = value; - mutate(); + set total(value) { + state.total = value; + mutate("total"); }, - get showEstimate() { - return state.showEstimate; + get showTotal() { + return state.showTotal; }, - set showEstimate(value) { - state.showEstimate = value; - mutate(); + set showTotal(value) { + state.showTotal = value; + mutate("showTotal"); }, get passive() { return state.passive; }, set passive(value) { state.passive = value; - mutate(); + mutate("passive"); }, get hidden() { return state.hidden; }, set hidden(value) { state.hidden = value; - mutate(); + mutate("hidden"); }, - maxHeight: null, - // get maxHeight() { - // return state.maxHeight; - // }, - // set maxHeight(value) { - // state.maxHeight = value; - // mutate(); - // }, get sortChildren() { return state.sortChildren; }, set sortChildren(value) { state.sortChildren = value; - value && state.children.sort(value); - state.signal("draw"); + mutate("sortChildren"); + }, + get estimatedTime() { + return state.estimatedTime; + }, + set estimatedTime(date) { + if (!estimator) { + state.estimatedTime = date; + mutate("estimatedTime"); + } + }, + get valueFormatter() { + return state.valueFormatter; + }, + set valueFormatter(value) { + state.valueFormatter = value; + mutate("valueFormatter"); }, inc(delta = 1) { state.value += delta; - if (state.estimate > 0 && state.value >= state.estimate) { - end(); + if (state.total > 0 && state.value >= state.total) { + endNode(owner, state); } else { - mutate(); + mutate("value"); } }, - log: log.scoped(""), - end: () => void end(), - [Symbol.dispose]: () => void end(), + log: scope, + end: () => void endNode(owner, state), + [Symbol.dispose]: () => void endNode(owner, state), }; return [state, binding]; } -function formatProgress(now: number, list: Internal[]): string { +const noop = () => {}; +noop[Symbol.dispose] = noop; + +/** minimal implementation of {@linkcode Node} that has no I/O */ +export const nullNode: Node = { + start: () => nullNode, + get text() { + return "[detached]"; + }, + set text(_) { + }, + get value() { + return 0; + }, + set value(_) { + }, + get total() { + return 0; + }, + set total(_) { + }, + inc: noop, + log: { + info: noop, + warn: noop, + error: noop, + log: noop, + debug: noop, + scoped() { + return this; + }, + tee: () => noop, + }, + end: () => {}, + [Symbol.dispose]: () => {}, +}; + +function endNode(owner: Root, state: Internal) { + if (state.detached) return; + state.children.forEach((child) => endNode(owner, child)); + state.detached = true; + globalKeyPool.recycle(state.key); + let title = state.text; + if (state.parent) { + const i = state.parent.children.indexOf(state); + ASSERT(i !== -1); + let p: Internal | null = state; + while (p = p.parent) title = p.text + " / " + title; + state.parent.children.splice(i, 1); + } + owner.emit("node-end", state); + state.logs = []; + state.parent = null; +} + +/** Convert the top level `ReadOnlyNode[]` into ANSI text. */ +export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string { let out = ""; for (const top of list) { if (top.passive && !hasChildren(top)) continue; - out += renderMainLine(top, now, 0) + "\n"; + out += renderAnsiMainLine(top, now, 0) + "\n"; out += renderChildren(top, now, []); } return out.trimEnd(); } -/** These functions are used to test `lib/progress` */ -export namespace internals { - export function node( - text: string, - props: Partial, - children: Internal[], - ): Internal { - const [node] = newNode(text); - Object.assign(node, props); - node.children.push(...children); - return node; - } - export const format = formatProgress; -} - const spinnerFps = 12.5; - -const box = { - tee: "├─ ", - line: "│ ", - langle: "└─ ", -}; +const box = { tee: "├─ ", line: "│ ", langle: "└─ " }; const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; const fullBar = "█"; const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] .map((frame) => ansi.style(ansi.fgBlue, frame)); -function renderMainLine(state: Internal, now: number, depth: number) { - if (state.estimate && state.showEstimate) { +function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { + const { text, total, showTotal, value, estimatedTime } = state; + const dateNow = Date.now(); + const estimate = estimatedTime && (estimatedTime > dateNow + 1000) + ? ", " + + string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000)) + : ""; + const valueFormatted = state.valueFormatter(value, total); + if (total && showTotal) { return ansi.style( ansi.bgBrightBlack + ansi.fgBlue, - getUnicodeBar( - state.value / state.estimate, + formatUnicodeBar( + value / total, Math.max(4, depth > 0 ? 12 : 25), ), ) + - ` [${state.value}/${state.estimate}] ` + state.text; + (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ") + + text; } const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; - return (depth === 0 ? spinner[frame] + " " : "") + - (state.value > 0 ? `[${state.value}] ` : "") + state.text; + return ((depth === 0 ? spinner[frame] + " " : "") + + (valueFormatted ? `[${valueFormatted}] ` : "") + text); } -function hasChildren(states: Internal): boolean { +function hasChildren(states: ReadOnlyNode): boolean { return states.children.some((x) => !x.hidden && (!x.passive || hasChildren(x)) ); } -function renderChildren(state: Internal, now: number, depth: boolean[]) { - let maxHeight = 50; // TODO: +function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { + let maxHeight = 50; // TODO: flexible layout let truncated = 0; let out = ""; let { children } = state; @@ -313,7 +679,7 @@ function renderChildren(state: Internal, now: number, depth: boolean[]) { let item = ""; const left = depth.map((x) => x ? box.line : " ").join(""); item += left + (i === length - 1 && !truncated ? box.langle : box.tee); - item += renderMainLine(child, now, 1 + depth.length) + "\n"; + item += renderAnsiMainLine(child, now, 1 + depth.length) + "\n"; item += renderChildren(child, now, depth.concat(i < length - 1)); const h = string.countNewlines(item); if (h > maxHeight) { @@ -323,7 +689,7 @@ function renderChildren(state: Internal, now: number, depth: boolean[]) { const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; for (const line of logLines) { item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + - " " + line + "\n"; + " " + log.formatMessage(line, true) + "\n"; } maxHeight -= h + logLines.length; out += item; @@ -340,7 +706,7 @@ function renderChildren(state: Internal, now: number, depth: boolean[]) { * which did ffmpeg handling. It is probably one of the coolest progress bars * ever imagined. */ -export function getUnicodeBar(progress: number, width: number): string { +export function formatUnicodeBar(progress: number, width: number): string { if (progress >= 1) return fullBar.repeat(width); if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); @@ -356,58 +722,143 @@ export function getUnicodeBar(progress: number, width: number): string { return fill + partChar + empty; } -/** Run a progress tree without a dependency on a TTY */ -export class Headless { - constructor( - /** - * `startWidget` is only ever called from `start` - * `writeLine` is used for logs from completed nodes - */ - public env: Pick, - ) {} +/** + * configure a `Root` to display its contents to a `log.HeadlessWidgetHost`. + * this is used by the global progress instance to output to the terminal + * ```ts + * const globalProgress = new progress.Root(); + * progress.attachToScreen(log); + * ``` + * unit tests use a mock screen instead of the terminal. + * ```ts + * const screen = new TestWidgetHost(); + * const root = new progress.Root(screen.delay); + * root.attachToScreen(screen); + * // can use the progress API + * root.start("hello"); + * // and then examine the contents + * screen.expectFrame(...); + * ``` + */ +export function attachToScreen( + root: Root, + { writeLine, startWidget }: Pick< + log.HeadlessWidgetHost, + "writeLine" | "startWidget" + >, +): ts.Dispose { + const stack = new DisposableStack(); - active: Internal[] = []; + let rerender: (() => void) | null = null; + const widget: log.Widget = { + format: (now) => root.active ? formatAnsi(now, root.active) : null, + onChange: (cb) => (rerender = cb, () => rerender = null), + fps: spinnerFps, + }; - start(text: string, opts?: StartOptions): Node { - const [state, node] = newNode(text, opts); - this.active.push(state); - state.signal = this.#signal; - const rerender = this.#rerender; + stack.use(root.on("change", (items) => { if (rerender) rerender(); - else log.startWidget(this.widget); - return node; - } - - widget: log.Widget = { - format: (now) => formatProgress(now, this.active), - onChange: ( - rerender, - ) => (this.#rerender = rerender, () => this.#rerender = null), - }; - - #rerender?: (() => void) | null; - #signal = (msg: Signal) => { - if (msg === "draw") this.#rerender?.(); - else if (msg.type === "line") this.env.writeLine(msg.line); - else if (msg.type === "end") { - const indexOfActive = this.active.indexOf(msg.state); - if (indexOfActive !== -1) this.active.splice(indexOfActive, 1); - if (msg.logs.length === 0) { - this.#rerender?.(); - return; - } - const { logs } = msg; + else if (items.length > 0) startWidget(widget); + })); + stack.use( + root.on( + "node-detached-log", + (msg) => writeLine(log.formatMessage(msg, true)), + ), + ); + stack.use(root.on("node-end", (node) => { + let title = node.text; + let p: ReadOnlyNode | null = node; + while (p = p.parent) title = p.text + " / " + title; + const { logs } = node; + if (logs.length > 0) { const count = `${logs.length} line${logs.length === 1 ? "" : "s"}`; - const header = `[${count} from ${msg.title}]`; - this.env.writeLine(ansi.style(ansi.fgBrightBlack, header)); - this.env.writeLine(msg.logs.join("\n")); + const header = `[${count} from ${title}]`; + writeLine(ansi.style(ansi.fgBrightBlack, header)); + writeLine(logs.map((msg) => log.formatMessage(msg, true)).join("\n")); } - }; + })); + + return ts.defer(() => stack[Symbol.dispose]); +} + +class KeyPool { + next = 1 as T; + old: T[] = []; + + get() { + const existing = this.old.shift(); + if (existing) return existing as T; + const next = this.next = this.next + 1 as T; + return next as T; + } + + recycle(k: T) { + this.old.push(k); + } +} + +/** stateful estimation by providing samples */ +export interface EstimationAlgorithm { + /** + * given a timestamp `time` and a progress value `progress`, return the + * timestamp that the action will be finished on. + */ + sample(time: number, progress: number): number | null; +} + +/** + * Exponential Moving Average. + * this is the algorithm used for the default progress estimator. + * https://en.wikipedia.org/wiki/Exponential_smoothing + */ +export class Ema implements EstimationAlgorithm { + start: number | null = null; + estimate: number | null = null; + /** smoothing factor (0-1) */ + alpha: number = 0.25; + + constructor(alpha: number = 0.1) { + this.alpha = alpha; + } + + /** + * given a timestamp `time` and a progress value `progress`, return the + * timestamp that the action will be finished on. + */ + sample(time: number, progress: number): number | null { + ASSERT( + progress >= 0 && progress < 1, + `progress must be a number between 0 and 1, got ${progress}`, + ); + if (this.start == null) { + this.start = time; + return null; + } + + const value = (time - this.start) / progress; + this.estimate = this.estimate != null + ? ((1 - this.alpha) * this.estimate + + this.alpha * value) + : value; + + return time + (1 - progress) * this.estimate; + } + + reset(): void { + this.start = null; + this.estimate = null; + } } -const global = /** @__PURE__ */ new Headless(log); +const globalKeyPool = new KeyPool(); +const global = + /** @__PURE__ */ ((root = new Root()) => (attachToScreen(root, log), root))(); +import * as async from "./async.ts"; import * as log from "./log.ts"; import * as ansi from "./string/ansi.ts"; +import * as ts from "./ts.ts"; import * as string from "./string.ts"; import { ASSERT, UNWRAP } from "./assert.ts"; +import { Events } from "./Events.ts"; diff --git a/lib/string.ts b/lib/string.ts index 969aefea9f98a372a7020d4913183e544ab9f916..f97eca0b73ea9e07052f339fe39a92b01df1d663 100644 --- a/lib/string.ts +++ b/lib/string.ts @@ -32,3 +32,52 @@ export function escapeShellArgument(s: string): string { if (s.startsWith("'")) complex = complex.slice(2); return complex; } + +const te = new TextEncoder(); +export function encodeUtf8(input: string): Uint8Array { + return te.encode(input) as Uint8Array; +} + +const td = new TextDecoder(); +export function decodeUtf8(input: Uint8Array): string { + return td.decode(input); +} + +const byteUnits = "kMGTPEZYRQ"; +export function formatByteSize(bytes: number) { + if (!Number.isFinite(bytes)) return bytes.toString(); + let prefix = ""; + if (bytes < 0) bytes = -bytes, prefix = "-"; + if (bytes < 1000) return (Math.round(bytes * 100) / 100) + "B"; + let unit = 0; + do { + bytes /= 1000; + unit += 1; + } while (bytes >= 1000); + return prefix + + bytes.toFixed( + Math.floor(bytes) === bytes || unit === 1 ? 0 : bytes > 100 ? 1 : 2, + ) + + byteUnits.charAt(unit - 1) + "B"; +} + +export function formatBinaryByteSize(bytes: number) { + let prefix = ""; + if (bytes < 0) bytes = -bytes, prefix = "-"; + if (bytes < 1024) return (Math.round(bytes * 100) / 100) + "B"; + let unit = 0; + do { + bytes /= 1024; + unit += 1; + } while (bytes >= 1024); + return prefix + (Math.round(bytes * 100) / 100) + + byteUnits.charAt(unit - 1) + "iB"; +} + +// TODO: +export function formatDurationLetters(seconds: number) { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + if (minutes < 1) return `${remainingSeconds}s`; + return `${minutes}m${remainingSeconds}s`; +} -- 2.54.0