1// @effect-diagnostics globalDate:off globalTimers:off - Vendored @clo/lib has no Effect dependency.
2import { UNWRAP } from "./assert.ts";
3import type { Events } from "./Events.ts";
4import type * as log from "./log.ts";
5import * as math from "./math.ts";
6import type { ReadOnlyNode, Root } from "./progress.ts";
7import { isVisible, maxLogLines, remainingSeconds } from "./progress-format.ts";
8import * as string from "./string.ts";
9import * as ansi from "./string/ansi.ts";
10import * as ts from "./ts.ts";
11
12/**
13 * draw `root` into the shadow root of `host`, after the Windows 7 progress bar
14 * and busy cursor, on the animation frame after each change, until disposed.
15 * the shadow root keeps the page and the tree from styling each other, and the
16 * tree follows the page's `color-scheme`. `host` must accept a shadow root,
17 * such as a `div`; one rendered by {@linkcode formatHtml} is taken over.
18 * ```ts
19 * const root = new progress.Root();
20 * const detach = progress.attachToDocument(root, document.getElementById("progress")!);
21 * await build(root);
22 * detach();
23 * ```
24 */
25export function attachToDocument<R, M extends Events.Map>(
26 root: Root<R, M>,
27 host: Element,
28): ts.Dispose {
29 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
30 shadow.innerHTML = formatShadow("");
31 const tree = UNWRAP(shadow.lastElementChild);
32 const template = document.createElement("template");
33 const elements = new WeakMap<ReadOnlyNode, Element>();
34 const changedAt = new WeakMap<ReadOnlyNode, number>();
35 const staleLogs = new Set<ReadOnlyNode>();
36 let frame: number | null = null;
37 let tick: ts.Timer | undefined;
38 let tickIn = Infinity;
39
40 const redraw = () => void (frame ??= requestAnimationFrame(draw));
41 function draw() {
42 frame = null;
43 for (const node of staleLogs) {
44 const logs = elements.get(node)?.children[2];
45 if (logs) logs.innerHTML = formatLogs(node.logs);
46 }
47 staleLogs.clear();
48 clearTimeout(tick);
49 tickIn = Infinity;
50 sync(tree, root.active);
51 if (tickIn !== Infinity) tick = setTimeout(redraw, tickIn);
52 }
53 function sync(list: Element, nodes: readonly ReadOnlyNode[]) {
54 let next = list.firstElementChild;
55 for (const node of nodes) {
56 if (!isVisible(node)) continue;
57 // estimates show whole seconds, rounded, so each changes on a half second
58 const ms = (node.estimatedTime ?? 0) - Date.now();
59 if (ms > 0) tickIn = Math.min(tickIn, ((ms + 500) % 1000) + 5);
60 let li = elements.get(node);
61 if (li) {
62 update(li, node, changedAt);
63 } else {
64 template.innerHTML = formatNode(node, "");
65 elements.set(node, (li = UNWRAP(template.content.firstElementChild)));
66 changedAt.set(node, performance.now());
67 }
68 if (li === next) next = li.nextElementSibling;
69 else list.insertBefore(li, next);
70 sync(UNWRAP(li.children[1]), node.children);
71 }
72 while (next) {
73 const stale = next;
74 next = next.nextElementSibling;
75 stale.remove();
76 }
77 }
78
79 const listeners = [
80 root.on("node-start", redraw),
81 root.on("node-change", (node, key) => {
82 if (key === "logs") staleLogs.add(node);
83 redraw();
84 }),
85 root.on("node-end", redraw),
86 ];
87 draw();
88 return ts.defer(() => {
89 for (const off of listeners) off();
90 if (frame !== null) cancelAnimationFrame(frame);
91 clearTimeout(tick);
92 shadow.replaceChildren();
93 });
94}
95
96/**
97 * the markup {@linkcode attachToDocument} draws for `list`, as a declarative
98 * shadow root to place inside the element it later takes over. browsers attach
99 * it only while parsing a document, such as a server rendered page.
100 */
101export function formatHtml(list: readonly ReadOnlyNode[]): string {
102 const format = (nodes: readonly ReadOnlyNode[]): string =>
103 nodes
104 .filter(isVisible)
105 .map((node) => formatNode(node, format(node.children)))
106 .join("");
107 return `<template shadowrootmode="open">${formatShadow(format(list))}</template>`;
108}
109
110const formatShadow = (items: string) =>
111 `<style>${css}</style><ol class=tree role=list>${items}</ol>`;
112
113/** the parts of a node's markup that change after it is drawn */
114function fields(node: ReadOnlyNode) {
115 const fraction =
116 node.showTotal && node.total > 0 ? math.clamp(node.value / node.total, 0, 1) : null;
117 const remaining = remainingSeconds(node);
118 return {
119 className: fraction === null ? "spin" : fraction === 1 ? "bar full" : "bar",
120 fraction,
121 count: formatCount(node),
122 eta: remaining
123 ? string.formatDurationLetters(remaining).replace(/(?<=\D)(?=\d)/g, " ") + " left"
124 : "",
125 };
126}
127
128/** `attachToDocument` relies on this layout: row, children, logs */
129function formatNode(node: ReadOnlyNode, children: string): string {
130 const { className, fraction, count, eta } = fields(node);
131 const id = `n${node.key}`;
132 const value =
133 fraction === null
134 ? ""
135 : ` aria-valuenow="${node.value}" aria-valuemax="${node.total}" style="--p:${fraction}"`;
136 return (
137 `<li class="${className}"><div class=row>` +
138 `<div class=indicator role=progressbar aria-labelledby=${id}${value}><i></i></div>` +
139 `<span class=count><b>${esc(count[0])}</b><span>${esc(count[1])}</span></span>` +
140 `<span class=eta>${eta}</span>` +
141 `<span class=label id=${id}>${esc(node.text)}</span>` +
142 `</div><ol role=list>${children}</ol><div class=logs>${formatLogs(node.logs)}</div></li>`
143 );
144}
145
146/**
147 * bring `li` from {@linkcode formatNode} up to date, except its children and
148 * logs. `changedAt` holds when each bar last moved.
149 */
150function update(li: Element, node: ReadOnlyNode, changedAt: WeakMap<ReadOnlyNode, number>) {
151 const { className, fraction, count, eta } = fields(node);
152 const [indicator, counter, estimate, label] = UNWRAP(li.firstElementChild).children;
153 const [value, total] = UNWRAP(counter).children;
154 const progressbar = UNWRAP(indicator);
155 setAttribute(li, "class", className);
156 setText(UNWRAP(value), count[0]);
157 setText(UNWRAP(total), count[1]);
158 setText(UNWRAP(estimate), eta);
159 setText(UNWRAP(label), node.text);
160 if (fraction === null) {
161 for (const name of ["aria-valuenow", "aria-valuemax", "style"])
162 setAttribute(progressbar, name, null);
163 return;
164 }
165 const shown = math.clamp(
166 Number(progressbar.getAttribute("aria-valuenow")) /
167 Number(progressbar.getAttribute("aria-valuemax")),
168 0,
169 1,
170 );
171 setAttribute(progressbar, "aria-valuenow", String(node.value));
172 setAttribute(progressbar, "aria-valuemax", String(node.total));
173 if (fraction === shown) return;
174 // a forward glide lasts as long as the last update took to arrive, so steady
175 // progress moves steadily; going back snaps, as the original does
176 const now = performance.now();
177 const glide = fraction > shown ? Math.min(now - (changedAt.get(node) ?? now), 1000) : 0;
178 changedAt.set(node, now);
179 setAttribute(progressbar, "style", `--p:${fraction};--glide:${Math.round(glide)}ms`);
180}
181
182function setAttribute(element: Element, name: string, value: string | null) {
183 if (element.getAttribute(name) === value) return;
184 if (value === null) element.removeAttribute(name);
185 else element.setAttribute(name, value);
186}
187
188function setText(element: Element, text: string) {
189 if (element.textContent !== text) element.textContent = text;
190}
191
192/** `[value, total]`, split at the formatter's slash so the total can be styled apart */
193function formatCount({ value, total, showTotal, valueFormatter }: ReadOnlyNode): [string, string] {
194 const text = valueFormatter(value, total);
195 const slash = text.lastIndexOf("/");
196 if (slash === -1) return [text, ""];
197 return [text.slice(0, slash), showTotal ? text.slice(slash + 1) : ""];
198}
199
200/** the last lines of `logs`. partial writes join into lines of their own. */
201function formatLogs(logs: readonly log.Message[]): string {
202 let start = logs.length;
203 for (let breaks = 0; start > 0 && breaks <= maxLogLines;) {
204 const { text, newline } = UNWRAP(logs[--start]);
205 breaks += string.countNewlines(text) + (newline === false ? 0 : 1);
206 }
207 const lines: { kind: string; html: string }[] = [];
208 let partial: { kind: string; html: string } | null = null;
209 for (const { text, newline, level = "info", scope } of logs.slice(start)) {
210 ansi
211 .toHtml(text)
212 .split("\n")
213 .forEach((html, i) => {
214 if (newline !== false) {
215 // a message starts its own line, so a write's trailing newline leaves no blank one
216 if (i === 0 && partial?.html === "") lines.pop();
217 const prefix = i === 0 && scope ? `<span class=scope>${esc(scope)}</span> ` : "";
218 lines.push({ kind: i === 0 ? level : "more", html: prefix + html });
219 } else if (i === 0 && partial) {
220 partial.html += html;
221 } else {
222 lines.push((partial = { kind: "raw", html }));
223 }
224 });
225 if (newline !== false) partial = null;
226 }
227 while (lines.at(-1)?.html === "") lines.pop();
228 return lines
229 .slice(-maxLogLines)
230 .map(({ kind, html }) => `<div class=${kind}>${html}</div>`)
231 .join("");
232}
233
234const esc = string.escapeHtmlContent;
235
236/** inline svg for a background image */
237const svg = (body: string) =>
238 `url("data:image/svg+xml,${encodeURIComponent(
239 `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'>${body}</svg>`,
240 )}")`;
241
242// light-dark() resolves against `color-scheme`, which is inherited from the
243// host; `all: initial` keeps every other inherited property out.
244const css = /* @__PURE__ */ (() => `
245:host{display:block}
246.tree{all:initial;display:block;color-scheme:inherit;container-type:inline-size;
247 font:12px/23px "Segoe UI",system-ui,sans-serif;color:var(--progress-foreground,light-dark(#1e1e1e,#e3e5e8));
248 --line:var(--progress-border,light-dark(#a9b3bd,#5a626c));--muted:var(--progress-muted,light-dark(#56616c,#a4aeb9));${ansi.htmlColors}}
249ol{margin:0;padding:0}
250li{display:flow-root;position:relative}
251.tree>li+li{margin-top:4px}
252.row{display:flex;flex-wrap:wrap;align-items:center;column-gap:6px;min-height:23px;white-space:nowrap}
253.label{overflow:hidden;text-overflow:ellipsis;min-width:0}
254.tree>li>.row>.label{font-size:13px;color:var(--progress-accent,light-dark(#039,#8fb4ff))}
255.count,.eta{flex:none;margin:4px 0;padding:0 6px;border-radius:8px;font-size:11px;line-height:15px;
256 font-variant-numeric:tabular-nums;color:var(--muted);
257 background:linear-gradient(light-dark(#fdfeff,#363b43),light-dark(#edf1f5,#2b3037));
258 box-shadow:inset 0 0 0 1px light-dark(#d5dce4,#40474f)}
259.count:has(>b:empty),.eta:empty{display:none}
260.count>b{font-weight:600;color:var(--progress-foreground,light-dark(#2c3640,#d5dbe1))}
261.count>span:not(:empty)::before{content:"/";margin:0 .2em;opacity:.55}
262
263/* bars lead the row, wider at the top level, as in the terminal. the track is
264 a 1px frame, 6px of gloss, a stretched body, and 5px of bottom light */
265.tree>li{--bar:176}
266li li{--bar:96}
267@container (width < 400px){.tree>li{--bar:112}li li{--bar:64}}
268.bar>.row>.indicator{position:relative;flex:none;box-sizing:border-box;width:calc(var(--bar) * 1px);
269 height:15px;margin:4px 0;border:1px solid transparent;border-radius:2px;
270 background:
271 linear-gradient(light-dark(#f3f3f3,#34383e),light-dark(#fcfcfc,#3b3f45) 1.5px,
272 light-dark(#dbdbdb,#2f3237) 6px,light-dark(#cacaca,#26292d) 6px,
273 light-dark(#cacaca,#26292d) calc(100% - 5px),light-dark(#d5d5d5,#2b2e33) calc(100% - 1px),
274 light-dark(#eaeaea,#33373c)) padding-box,
275 linear-gradient(light-dark(#b2b2b2,#4b5058) 0 1px,light-dark(#bdbdbd,#464b53) 1px,
276 light-dark(#919191,#393d44) calc(100% - 1px),light-dark(#8c8c8c,#393d44) calc(100% - 1px))
277 border-box}
278.indicator>i{position:absolute;top:0;bottom:0;left:0;width:calc(var(--p) * 100%);overflow-x:clip;
279 --gloss:#d2f1fc 1.5px,#addcf3 3.5px,#9ad3ef;--body:var(--progress-accent,#0794d1);--rest:var(--progress-accent,#1ea0d8);--edge:var(--progress-accent,#035f8f);
280 --glow:160 225 255;--shade:color-mix(in srgb,var(--edge) 40%,#0000);
281 background:
282 linear-gradient(90deg,#0000 1px,var(--shade) 1px,var(--shade) 5px,#0000 27px),
283 linear-gradient(270deg,#0000 1px,var(--shade) 1px,#0000 14px),
284 linear-gradient(var(--gloss) 6px,var(--body) 6px,var(--body) calc(100% - 5px),var(--rest));
285 transition:width var(--glide,0s) linear}
286/* a glow sweeps the fill at one speed for every bar, over the frame too, while
287 the bottom light swells under it */
288.indicator>i::before{content:"";position:absolute;left:0;right:0;bottom:0;height:5px;
289 background:linear-gradient(#0000,rgb(var(--glow)/.6));animation:swell 2.4s ease-in-out infinite}
290.indicator>i::after{content:"";position:absolute;top:-1px;bottom:-1px;left:0;width:64px;
291 background:linear-gradient(90deg,#0000,rgb(var(--glow)/.03) 8.3%,rgb(var(--glow)/.08) 16.7%,
292 rgb(var(--glow)/.2) 25%,rgb(var(--glow)/.36) 33.3%,rgb(var(--glow)/.53) 41.7%,
293 rgb(var(--glow)/.6),rgb(var(--glow)/.53) 58.3%,rgb(var(--glow)/.36) 66.7%,
294 rgb(var(--glow)/.2) 75%,rgb(var(--glow)/.08) 83.3%,rgb(var(--glow)/.03) 91.7%,#0000);
295 animation:glow 2.4s linear infinite}
296@keyframes glow{from{translate:-100%}50%,to{translate:176px}}
297@keyframes swell{from{opacity:.4}25%{opacity:1}65%,88%{opacity:0}to{opacity:.4}}
298:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::before,
299:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::after{content:none}
300li:has(>.logs>.warn)>.row>.indicator>i{
301 --gloss:#fffdcd 1.5px,#fffba1 3.5px,#fffa8b;--body:#d2ca00;--rest:#fef400;--edge:#8f8600}
302li:has(>.logs>.error)>.row>.indicator>i{
303 --gloss:#ffcdcd 1.5px,#ffa1a1 3.5px,#ff8b8b;--body:#d20000;--rest:#fe0000;--edge:#8f0000}
304
305/* the busy ring leads a top-level row, lit from above, with a highlight
306 running clockwise; below the top level the terminal shows no spinner */
307.spin>.row>.indicator{position:relative;flex:none;width:15px;height:15px;margin:4px 0;border-radius:50%;
308 background:conic-gradient(#9bc6da,#8dc2d0 30deg,#6daebd 60deg,#2b8982 90deg,#319f9d 120deg,
309 #4dc7bb 150deg,#5ecfc5 180deg,#4fc6bb 210deg,#2da09e 240deg,#238382 270deg,#6db2bb 300deg,
310 #91c5d1 330deg,#9bc6da);
311 mask:radial-gradient(closest-side,#0000 61%,#000 67%)}
312li li.spin>.row>.indicator{display:none}
313.spin>.row>.indicator::before,.spin>.row>.indicator::after{content:"";position:absolute;inset:0;border-radius:50%}
314.spin>.row>.indicator::before{animation:spin .9s linear infinite;
315 background:conic-gradient(from -150deg,#0000,#2fd4dc99 40deg,#4fe6eae6 90deg,#8ff3f4 125deg,
316 #e8fcfd 150deg,#b4eef0b3 162deg,#0000 180deg)}
317.spin>.row>.indicator::after{background:radial-gradient(closest-side,#00323c73 64%,#00323c00 76%,
318 #fff3 86%,#00323c00 92%,#00323c80)}
319.spin>.row>.indicator>i{display:none}
320@keyframes spin{to{rotate:1turn}}
321
322/* tree lines: a node's children hang from the start of its row, and each child
323 is drawn with the terminal's ├─, │ and └─ */
324li li{padding-left:22px}
325li>ol{position:relative}
326li>ol:not(:empty)::before{content:"";position:absolute;left:7px;top:-4px;height:5px;
327 border-left:1px solid var(--line)}
328li li::before,li li::after{content:"";position:absolute;left:7px;border:0 solid var(--line)}
329li li::before{top:0;bottom:-1px;border-left-width:1px}
330li li::after{top:11px;width:11px;border-top-width:1px}
331li li:last-child::before{bottom:auto;height:12px}
332
333.logs{margin:1px 0 4px;padding:3px 8px;border-radius:4px;
334 background:var(--progress-background,light-dark(#f3f5f8,#24282e));box-shadow:inset 0 0 0 1px light-dark(#e1e6ec,#31363d);
335 font:11px/16px ui-monospace,"Cascadia Mono",Consolas,"SF Mono",Menlo,monospace;color:var(--muted)}
336.logs:empty{display:none}
337.logs>div{overflow:hidden;text-overflow:ellipsis;white-space:pre}
338.logs>.more{padding-left:16px}
339.logs>.debug{opacity:.7}
340.logs>:is(.info,.warn,.error)::before{content:"";display:inline-block;width:11px;height:11px;
341 margin-right:5px;vertical-align:-2px;background:center/contain no-repeat}
342.logs>.info::before{background-image:${svg(
343 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#7cbaf6'/><stop offset='1' stop-color='#1557b8'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#0c3f8c' stroke-width='.6'/><rect x='5.25' y='5' width='1.5' height='4.2' rx='.5' fill='#fff'/><circle cx='6' cy='3.3' r='.9' fill='#fff'/>",
344)}}
345.logs>.warn::before{background-image:${svg(
346 "<defs><linearGradient id='g' x2='0' y2='1'><stop stop-color='#ffe680'/><stop offset='1' stop-color='#f0b400'/></linearGradient></defs><path d='M6 .8l5.4 10H.6z' stroke-linejoin='round' fill='url(#g)' stroke='#b58300' stroke-width='.7'/><rect x='5.3' y='4' width='1.4' height='3.8' rx='.5' fill='#333'/><circle cx='6' cy='9.2' r='.8' fill='#333'/>",
347)}}
348.logs>.error::before{background-image:${svg(
349 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#ff8a80'/><stop offset='1' stop-color='#c41a1a'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#8a0f0f' stroke-width='.6'/><path d='M4 4l4 4m0-4l-4 4' stroke='#fff' stroke-width='1.5' stroke-linecap='round'/>",
350)}}
351.scope{color:var(--progress-secondary,light-dark(#7a6a9e,#b5a6d8))}
352
353
354@media (prefers-reduced-motion:reduce){
355 .indicator>i{transition:none}
356 .indicator>i::before,.indicator>i::after{content:none}
357 .spin>.row>.indicator::before{animation:pulse 1.8s ease-in-out infinite alternate}
358}
359@keyframes pulse{from{opacity:.25}}
360@media (forced-colors:active){
361 .indicator{forced-color-adjust:none}
362 .bar>.row>.indicator{background:Canvas;border-color:CanvasText}
363 .indicator>i{background:Highlight}
364 .indicator>i::before,.indicator>i::after{content:none}
365 .spin>.row>.indicator{background:Highlight}
366}
367:host([data-progress-motion="static"]) .indicator,
368:host([data-progress-motion="static"]) .indicator>i,
369:host([data-progress-motion="static"]) .indicator::before,
370:host([data-progress-motion="static"]) .indicator::after,
371:host([data-progress-motion="static"]) .indicator>i::before,
372:host([data-progress-motion="static"]) .indicator>i::after{animation:none;transition:none}
373`)();