diff --git a/framework/bundle.ts b/framework/bundle.ts index 3a95c5ac440b1f88d031de7a96b1d3bf94abe898..603a2c407e47a41e8272927a5cdb882f2726ed4f 100644 --- a/framework/bundle.ts +++ b/framework/bundle.ts @@ -401,5 +401,5 @@ import * as sg from "#sitegen"; import type { PageOrView } from "./generate.ts"; import type * as view from "#sitegen/view"; import * as meta from "lib/render/meta.ts"; -import * as progress from "lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import { ASSERT, UNWRAP } from "lib/assert.ts"; diff --git a/framework/font.ts b/framework/font.ts index bc7e3594722facbe53b53bea51e179a927a2efb4..39953480d788c63c9b207cba91dcbe53756a7385 100644 --- a/framework/font.ts +++ b/framework/font.ts @@ -216,5 +216,5 @@ import * as incr from "./incremental.ts"; import * as crypto from "node:crypto"; import * as async from "lib/async.ts"; import * as error from "lib/error.ts"; -import * as progress from "../lib/log/progress.ts"; +import * as progress from "../lib/progress.ts"; import { ASSERT } from "lib/assert.ts"; diff --git a/framework/generate.ts b/framework/generate.ts index 4fa203076575b1aee1bf40ab0631de5ad420f6db..ccd29f9707cd7cdc859e27c2ff05fcce0f0ae291 100644 --- a/framework/generate.ts +++ b/framework/generate.ts @@ -401,7 +401,7 @@ import * as meta from "lib/render/meta.ts"; import * as render from "lib/render.ts"; import * as sg from "#sitegen"; import type { FileItem } from "#sitegen"; -import * as progress from "../lib/log/progress.ts"; +import * as progress from "../lib/progress.ts"; import * as bundle from "./bundle.ts"; import * as css from "./css.ts"; diff --git a/framework/incremental.ts b/framework/incremental.ts index 086640bde51f8b593dfc3f5621acbcc64d0c7706..a4468807c958e01ab90fe1b157916a5b554debe2 100644 --- a/framework/incremental.ts +++ b/framework/incremental.ts @@ -736,7 +736,7 @@ import * as util from "node:util"; import * as crypto from "node:crypto"; import * as zlib from "node:zlib"; -import * as progress from "lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import * as stack from "lib/log/stack.ts"; import { formatSize } from "@/file-viewer/format.ts"; diff --git a/lib/deno.json b/lib/deno.json index a8f612f26c6d11dca12a5692a56d5d52bceeb5f5..c30b6c44f84093bc7e6079bdf270a282ebbee992 100644 --- a/lib/deno.json +++ b/lib/deno.json @@ -1,7 +1,7 @@ { "name": "@clo/lib", "description": "high quality typescript libraries", - "version": "2.0.0", + "version": "3.0.0", "license": "MIT", "exports": { "./assert.ts": "./assert.ts", @@ -9,11 +9,11 @@ "./error.ts": "./error.ts", "./log.ts": "./log.ts", "./log/headless.ts": "./log/headless.ts", - "./log/progress.ts": "./log/progress.ts", "./log/stack.ts": "./log/stack.ts", "./Lru.ts": "./Lru.ts", "./mime.ts": "./mime.ts", "./node.ts": "./node.ts", + "./progress.ts": "./progress.ts", "./queue.ts": "./queue.ts", "./render.ts": "./render.ts", "./render/jsx-runtime.ts": "./render/jsx-runtime.ts", @@ -37,4 +37,5 @@ "readme.npm.md", "deno.lock" ] -} \ No newline at end of file +} + diff --git a/lib/log.ts b/lib/log.ts index 0fdcdd8b1286557a6e686c08427bd87604702031..2ac4ef49726dd87fd748e42c8e7d5558adbccaa5 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -1,6 +1,6 @@ /** * By using `lib/log.ts`, your application gets easy scoped logging as well as - * integration with terminal widgets such as `lib/log/progress.ts`. + * integration with terminal widgets such as `lib/progress.ts`. * * The pattern for using this module is to shadow the global `console` with a * local scope: diff --git a/lib/log/progress.sample.ts b/lib/log/progress.sample.ts deleted file mode 100644 index 8386b420fb4b5e26d6c298c213494c4810c8269e..0000000000000000000000000000000000000000 --- a/lib/log/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/log/progress.ts"; -import * as timers from "node:timers/promises"; diff --git a/lib/log/progress.ts b/lib/log/progress.ts deleted file mode 100644 index 85c60bd81d79b0e58560d7c45cc7d8e1768b825b..0000000000000000000000000000000000000000 --- a/lib/log/progress.ts +++ /dev/null @@ -1,416 +0,0 @@ -// 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. - -/** - * creates a new trackable unit of work as a child of this one. - * when given an estimate, a progress bar is rendered. - */ -export function start(text: string, opts?: StartOptions): Node { - return global.start(text, opts); -} - -export interface Ref { - /** - * creates a new trackable unit of work as a child of this one. - * when given an estimate, a progress bar is rendered. - */ - start(text: string, opts?: StartOptions): Node; -} - -export interface Node extends Ref, Disposable { - /** reactive */ - text: string; - /** reactive */ - value: number; - /** reactive */ - estimate: number; - /** - * reactive, default `true`. `false` will hides a bar + printing estimate, - * but preseving auto-end behavior of `inc` - */ - showEstimate: boolean; - /** - * reactive, default `false`. when true, this progress item is hidden if - * there are no children. - */ - passive: boolean; - /** - * reactive, default `false`. when true, this progress item is hidden - */ - 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; -} - -export interface StartOptions { - value?: number | undefined | null; - estimate?: number | undefined | null; - passive?: boolean | undefined | null; - hidden?: boolean | undefined | null; - showEstimate?: boolean | undefined | null; - maxHeight?: number | undefined | null; - sortChildren?: Node["sortChildren"] | undefined | null; -} - -/** 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(""); - return node; - })("root"); - -export { nullNode as null }; - -export type SortNode = - & Pick< - Node, - | "text" - | "value" - | "estimate" - | "passive" - | "hidden" - | "showEstimate" - | "sortChildren" - > - & { children: SortNode[] }; - -export interface Internal extends SortNode { - logs: string[]; - children: Internal[]; - parent: Internal | null; - signal(msg: Signal): void; -} - -type Signal = - | "draw" - | { type: "end"; title: string; logs: string[]; state: Internal } - | { type: "line"; line: string }; - -function newNode( - text: string, - opts: StartOptions = {}, -): [Internal, Node] { - const state: Internal = { - text, - value: opts.value ?? 0, - estimate: opts.estimate ?? 0, - showEstimate: opts.showEstimate ?? true, - passive: opts.passive ?? false, - hidden: opts.hidden ?? false, - logs: [], - children: [], - parent: null, - sortChildren: opts.sortChildren ?? null, - signal() { - throw new Error("no signaler"); - }, - }; - 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 = []; - } - function mutate() { - const { parent } = state; - parent?.sortChildren && parent.children.sort(parent.sortChildren); - state.signal("draw"); - } - const scope = headless.logger({ - log: { - writeLine(line) { - if (detached) return state.signal({ type: "line", line }); - state.logs.push(...line.split("\n")); - state.signal("draw"); - }, - }, - colors: process.stderr.isTTY, - }); - const binding: Node = { - start(text, opts) { - const [child, node] = newNode(text, opts); - state.children.push(child); - state.sortChildren && state.children.sort(state.sortChildren); - child.parent = state; - child.signal = state.signal; - state.signal("draw"); - return node; - }, - get text() { - return state.text; - }, - set text(value) { - state.text = value; - mutate(); - }, - get value() { - return state.value; - }, - set value(value) { - state.value = value; - mutate(); - }, - get estimate() { - return state.estimate; - }, - set estimate(value) { - state.estimate = value; - mutate(); - }, - get showEstimate() { - return state.showEstimate; - }, - set showEstimate(value) { - state.showEstimate = value; - mutate(); - }, - get passive() { - return state.passive; - }, - set passive(value) { - state.passive = value; - mutate(); - }, - get hidden() { - return state.hidden; - }, - set hidden(value) { - state.hidden = value; - mutate(); - }, - 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"); - }, - inc(delta = 1) { - state.value += delta; - if (state.estimate > 0 && state.value >= state.estimate) { - end(); - } else { - mutate(); - } - }, - log: scope, - end: () => void end(), - [Symbol.dispose]: () => void end(), - }; - return [state, binding]; -} - -function formatProgress(now: number, list: Internal[]): string { - let out = ""; - for (const top of list) { - if (top.passive && !hasChildren(top)) continue; - out += renderMainLine(top, now, 0) + "\n"; - out += renderChildren(top, now, []); - } - return out.trimEnd(); -} - -/** These functions are used to test `lib/log/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 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) { - return ansi.style( - ansi.bgBrightBlack + ansi.fgBlue, - getUnicodeBar( - state.value / state.estimate, - Math.max(4, depth > 0 ? 12 : 25), - ), - ) + - ` [${state.value}/${state.estimate}] ` + state.text; - } - const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; - return (depth === 0 ? spinner[frame] + " " : "") + - (state.value > 0 ? `[${state.value}] ` : "") + state.text; -} - -function hasChildren(states: Internal): boolean { - return states.children.some((x) => - !x.hidden && (!x.passive || hasChildren(x)) - ); -} - -function renderChildren(state: Internal, now: number, depth: boolean[]) { - let maxHeight = 50; // TODO: - let truncated = 0; - let out = ""; - let { children } = state; - children = children.filter((x) => - !x.hidden && (!x.passive || hasChildren(x)) - ); - if (children.length === 0) return ""; - for (let i = 0, { length } = children; i < length; i += 1) { - const child = UNWRAP(children[i]); - 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 += renderChildren(child, now, depth.concat(i < length - 1)); - const h = string.countNewlines(item); - if (h > maxHeight) { - truncated += 1; - continue; - } - const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; - for (const line of logLines) { - item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + - " " + line + "\n"; - } - maxHeight -= h + logLines.length; - out += item; - } - if (truncated) { - out += depth.map((x) => x ? box.line : " ").join("") + box.langle; - out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); - } - return out; -} - -/** - * This function is derived from an old program I wrote back in 2020 called `f` - * which did ffmpeg handling. It is probably one of the coolest progress bars - * ever imagined. - */ -export function getUnicodeBar(progress: number, width: number): string { - if (progress >= 1) return fullBar.repeat(width); - if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); - - const wholeWidth = Math.floor(progress * width); - const remainderWidth = (progress * width) % 1; - const partWidth = Math.floor(remainderWidth * 8); - let partChar = barChars[partWidth]; - if (width - wholeWidth - 1 < 0) partChar = ""; - - const fill = fullBar.repeat(wholeWidth); - const empty = " ".repeat(width - wholeWidth - 1); - - 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, - ) {} - - active: Internal[] = []; - - start(text: string, opts?: StartOptions): Node { - const [state, node] = newNode(text, opts); - this.active.push(state); - state.signal = this.#signal; - const rerender = this.#rerender; - 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; - 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 global = /** @__PURE__ */ new Headless(log); - -import * as process from "node:process"; -import * as log from "lib/log.ts"; -import * as headless from "lib/log/headless.ts"; -import * as ansi from "lib/string/ansi.ts"; -import * as string from "lib/string.ts"; -import { ASSERT, UNWRAP } from "lib/assert.ts"; diff --git a/lib/progress.sample.ts b/lib/progress.sample.ts new file mode 100644 index 0000000000000000000000000000000000000000..efc12ce0005393cbda11b7e41aea148fefb6dae6 --- /dev/null +++ b/lib/progress.sample.ts @@ -0,0 +1,73 @@ +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.ts b/lib/progress.ts new file mode 100644 index 0000000000000000000000000000000000000000..3f7b824c9db23367c7c93c819e1a2ef66639d911 --- /dev/null +++ b/lib/progress.ts @@ -0,0 +1,416 @@ +// 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. + +/** + * creates a new trackable unit of work as a child of this one. + * when given an estimate, a progress bar is rendered. + */ +export function start(text: string, opts?: StartOptions): Node { + return global.start(text, opts); +} + +export interface Ref { + /** + * creates a new trackable unit of work as a child of this one. + * when given an estimate, a progress bar is rendered. + */ + start(text: string, opts?: StartOptions): Node; +} + +export interface Node extends Ref, Disposable { + /** reactive */ + text: string; + /** reactive */ + value: number; + /** reactive */ + estimate: number; + /** + * reactive, default `true`. `false` will hides a bar + printing estimate, + * but preseving auto-end behavior of `inc` + */ + showEstimate: boolean; + /** + * reactive, default `false`. when true, this progress item is hidden if + * there are no children. + */ + passive: boolean; + /** + * reactive, default `false`. when true, this progress item is hidden + */ + 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; +} + +export interface StartOptions { + value?: number | undefined | null; + estimate?: number | undefined | null; + passive?: boolean | undefined | null; + hidden?: boolean | undefined | null; + showEstimate?: boolean | undefined | null; + maxHeight?: number | undefined | null; + sortChildren?: Node["sortChildren"] | undefined | null; +} + +/** 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(""); + return node; + })("root"); + +export { nullNode as null }; + +export type SortNode = + & Pick< + Node, + | "text" + | "value" + | "estimate" + | "passive" + | "hidden" + | "showEstimate" + | "sortChildren" + > + & { children: SortNode[] }; + +export interface Internal extends SortNode { + logs: string[]; + children: Internal[]; + parent: Internal | null; + signal(msg: Signal): void; +} + +type Signal = + | "draw" + | { type: "end"; title: string; logs: string[]; state: Internal } + | { type: "line"; line: string }; + +function newNode( + text: string, + opts: StartOptions = {}, +): [Internal, Node] { + const state: Internal = { + text, + value: opts.value ?? 0, + estimate: opts.estimate ?? 0, + showEstimate: opts.showEstimate ?? true, + passive: opts.passive ?? false, + hidden: opts.hidden ?? false, + logs: [], + children: [], + parent: null, + sortChildren: opts.sortChildren ?? null, + signal() { + throw new Error("no signaler"); + }, + }; + 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 = []; + } + function mutate() { + const { parent } = state; + parent?.sortChildren && parent.children.sort(parent.sortChildren); + state.signal("draw"); + } + const scope = headless.logger({ + log: { + writeLine(line) { + if (detached) return state.signal({ type: "line", line }); + state.logs.push(...line.split("\n")); + state.signal("draw"); + }, + }, + colors: process.stderr.isTTY, + }); + const binding: Node = { + start(text, opts) { + const [child, node] = newNode(text, opts); + state.children.push(child); + state.sortChildren && state.children.sort(state.sortChildren); + child.parent = state; + child.signal = state.signal; + state.signal("draw"); + return node; + }, + get text() { + return state.text; + }, + set text(value) { + state.text = value; + mutate(); + }, + get value() { + return state.value; + }, + set value(value) { + state.value = value; + mutate(); + }, + get estimate() { + return state.estimate; + }, + set estimate(value) { + state.estimate = value; + mutate(); + }, + get showEstimate() { + return state.showEstimate; + }, + set showEstimate(value) { + state.showEstimate = value; + mutate(); + }, + get passive() { + return state.passive; + }, + set passive(value) { + state.passive = value; + mutate(); + }, + get hidden() { + return state.hidden; + }, + set hidden(value) { + state.hidden = value; + mutate(); + }, + 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"); + }, + inc(delta = 1) { + state.value += delta; + if (state.estimate > 0 && state.value >= state.estimate) { + end(); + } else { + mutate(); + } + }, + log: scope, + end: () => void end(), + [Symbol.dispose]: () => void end(), + }; + return [state, binding]; +} + +function formatProgress(now: number, list: Internal[]): string { + let out = ""; + for (const top of list) { + if (top.passive && !hasChildren(top)) continue; + out += renderMainLine(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 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) { + return ansi.style( + ansi.bgBrightBlack + ansi.fgBlue, + getUnicodeBar( + state.value / state.estimate, + Math.max(4, depth > 0 ? 12 : 25), + ), + ) + + ` [${state.value}/${state.estimate}] ` + state.text; + } + const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; + return (depth === 0 ? spinner[frame] + " " : "") + + (state.value > 0 ? `[${state.value}] ` : "") + state.text; +} + +function hasChildren(states: Internal): boolean { + return states.children.some((x) => + !x.hidden && (!x.passive || hasChildren(x)) + ); +} + +function renderChildren(state: Internal, now: number, depth: boolean[]) { + let maxHeight = 50; // TODO: + let truncated = 0; + let out = ""; + let { children } = state; + children = children.filter((x) => + !x.hidden && (!x.passive || hasChildren(x)) + ); + if (children.length === 0) return ""; + for (let i = 0, { length } = children; i < length; i += 1) { + const child = UNWRAP(children[i]); + 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 += renderChildren(child, now, depth.concat(i < length - 1)); + const h = string.countNewlines(item); + if (h > maxHeight) { + truncated += 1; + continue; + } + const logLines = child?.logs.slice(-Math.min(3, maxHeight)) ?? []; + for (const line of logLines) { + item += left + box.line + " " + ansi.style(ansi.fgBrightBlack, ">") + + " " + line + "\n"; + } + maxHeight -= h + logLines.length; + out += item; + } + if (truncated) { + out += depth.map((x) => x ? box.line : " ").join("") + box.langle; + out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`); + } + return out; +} + +/** + * This function is derived from an old program I wrote back in 2020 called `f` + * which did ffmpeg handling. It is probably one of the coolest progress bars + * ever imagined. + */ +export function getUnicodeBar(progress: number, width: number): string { + if (progress >= 1) return fullBar.repeat(width); + if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); + + const wholeWidth = Math.floor(progress * width); + const remainderWidth = (progress * width) % 1; + const partWidth = Math.floor(remainderWidth * 8); + let partChar = barChars[partWidth]; + if (width - wholeWidth - 1 < 0) partChar = ""; + + const fill = fullBar.repeat(wholeWidth); + const empty = " ".repeat(width - wholeWidth - 1); + + 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, + ) {} + + active: Internal[] = []; + + start(text: string, opts?: StartOptions): Node { + const [state, node] = newNode(text, opts); + this.active.push(state); + state.signal = this.#signal; + const rerender = this.#rerender; + 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; + 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 global = /** @__PURE__ */ new Headless(log); + +import * as process from "node:process"; +import * as log from "lib/log.ts"; +import * as headless from "lib/log/headless.ts"; +import * as ansi from "lib/string/ansi.ts"; +import * as string from "lib/string.ts"; +import { ASSERT, UNWRAP } from "lib/assert.ts"; diff --git a/lib/readme.changes.md b/lib/readme.changes.md index ab60a01103611a0691b1ddaa1895f750edeaf7b2..b55c93839a1dee23bc165b8a58e02aec7d2a2730 100644 --- a/lib/readme.changes.md +++ b/lib/readme.changes.md @@ -1,5 +1,11 @@ # notable changes in clover's typescript library +## v3 + +### breaking + +- promote `log/progress.ts` to top level `progress.ts` + ## v2 ### breaking diff --git a/lib/readme.md b/lib/readme.md index 53639743a37e9c312fdf9bf30df0d0167bfeb61e..52af8270fdf59f5a34120d0ecd12e5a61546cd75 100644 --- a/lib/readme.md +++ b/lib/readme.md @@ -19,11 +19,11 @@ depend on each other. there are no external dependencies; code is easy to audit. - `error.ts` - interop with the `unknown` type in error handlers - `log.ts` - logging, terminal i/o, interactive widgets - `log/headless.ts` - use `log` and `log/progress` without a tty -- `log/progress.ts` - progress bar, spinner, and tree - `log/stack.ts` - stack trace parse and formatter - `Lru.ts` - least recently used cache - `mime.ts` - trimmed database of ext -> mime types - `node.ts` - the Node.js javascript runtime +- `progress.ts` - cli progress bars, nesting status indication - `queue.ts` - priority queue with rescheduling - `render.ts` - html generation with extensions. - `render/jsx-runtime.ts` - integration with JSX syntax diff --git a/lib/subprocess/ffmpeg.ts b/lib/subprocess/ffmpeg.ts index ee09a4f2560e07e24d1964d142c2501330088809..518cbd5d422b046300575d2fe6237f80d5ffb97c 100644 --- a/lib/subprocess/ffmpeg.ts +++ b/lib/subprocess/ffmpeg.ts @@ -8,7 +8,7 @@ export interface SpawnOptions { /** * Spawn an instance of `ffmpeg` and bind it's live status to the given * `progress.Node`. Logs are routed through the progress node, and dismissed on - * exit. `lib/log/progress.ts` will buffer and colocate the logs when running + * exit. `lib/progress.ts` will buffer and colocate the logs when running * parallel jobs. * * await ffmpeg.spawn({ @@ -194,5 +194,5 @@ import * as readline from "node:readline"; import * as process from "node:process"; import events from "node:events"; import * as path from "node:path"; -import type * as progress from "lib/log/progress.ts"; +import type * as progress from "lib/progress.ts"; import * as string from "lib/string.ts"; diff --git a/readme.md b/readme.md index ab8687cb566dd35ddad250f3fcac538e9a6a5733..adc27fd6e60601d720c7fa67488cca008d10baaa 100644 --- a/readme.md +++ b/readme.md @@ -24,7 +24,7 @@ tools power . - Dynamic pages. Static pages can be regenerated (and cached) at runtime. - Font subsetting to reduce page bandwidth. - **General purpose typescript libraries.** (See [`./lib`](./lib/readme.md)) - - [Logging](./lib/log.ts) and [progress](./lib/log/progress.ts) indicators. + - [Logging](./lib/log.ts) and [progress](./lib/progress.ts) indicators. - [Priority Queue](./lib/queue.ts) with automatic reschedulability. - [Meta Tags](./framework/lib/meta.ts) and Open Graph generation. - Databases with a minimal SQLite wrapper. diff --git a/src/deploy.ts b/src/deploy.ts index a0182e53766f1a927d5a09ad1337e6c7c24f2488..1fe508bea73ad73a38abc3896167f6867b847054 100644 --- a/src/deploy.ts +++ b/src/deploy.ts @@ -55,7 +55,7 @@ export async function main() { import * as incr from "../framework/incremental.ts"; import { generate } from "../framework/generate.ts"; -import * as progress from "lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import * as subprocess from "lib/subprocess.ts"; import * as rsync from "./file-viewer/rsync.ts"; import { Path } from "#sitegen/path"; diff --git a/src/file-viewer/bin/scan3.ts b/src/file-viewer/bin/scan3.ts index cfa40cd0f561de7413870e17f09ccf3983734a98..2269fab2d30e54e9b895c4a260531f744094ef29 100644 --- a/src/file-viewer/bin/scan3.ts +++ b/src/file-viewer/bin/scan3.ts @@ -1015,7 +1015,7 @@ import * as subprocess from "lib/subprocess.ts"; import { Path } from "#sitegen/path"; import * as queue from "../../../lib/queue.ts"; import * as log from "../../../lib/log.ts"; -import * as progress from "../../../lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import * as ts from "../../../lib/ts.ts"; import * as child_process from "node:child_process"; diff --git a/src/file-viewer/models/derived.ts b/src/file-viewer/models/derived.ts index 6ed3f1f78bba31af11fcb3c852c6ed7f59acb1a1..cf90ef90bda5e3e13ec7e7fc22641495913198c9 100644 --- a/src/file-viewer/models/derived.ts +++ b/src/file-viewer/models/derived.ts @@ -200,5 +200,5 @@ import { Path } from "#sitegen/path"; import * as fs from "node:fs"; import * as crypto from "node:crypto"; import * as queue from "lib/queue.ts"; -import * as progress from "lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import { ASSERT } from "lib/assert.ts"; diff --git a/src/file-viewer/rsync.ts b/src/file-viewer/rsync.ts index 925695d1af872dac7ef7e846f0aba347cedc6830..535c517dfd3083ff964ee4f4ae77e099c1dce1e9 100644 --- a/src/file-viewer/rsync.ts +++ b/src/file-viewer/rsync.ts @@ -183,5 +183,5 @@ export class Parse { import * as child_process from "node:child_process"; import * as readline from "node:readline"; import events from "node:events"; -import * as progress from "lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import { formatSize } from "@/file-viewer/format.ts"; diff --git a/src/package-lib.ts b/src/package-lib.ts index c2cc080675f5dd313cb7f303709e9848dd9d4181..b26cde479d91bcbcb1e0ebf7d9009b7a1b0dc313 100644 --- a/src/package-lib.ts +++ b/src/package-lib.ts @@ -219,7 +219,7 @@ export function main() { ); } -import * as progress from "lib/log/progress.ts"; +import * as progress from "lib/progress.ts"; import * as path from "node:path"; import * as fs from "node:fs/promises"; import * as subprocess from "lib/subprocess.ts";