| ... | ... | @@ -2,8 +2,9 @@ |
| 2 | 2 | * a progress reporting API that allows creating CLI spinners and progress bars |
| 3 | 3 | * that can be easily nested to visualize complex, parallel work. in addition |
| 4 | 4 | * to providing rich feedback in the terminal, a headless {@linkcode Root} node |
| 5 | | * can be constructed, which can be used to bind to {@link formatHtml|the DOM} |
| 6 | | * or transmit progress information {@link encodeStream|across a network}. |
| 5 | * can be constructed, which can be used to bind to |
| 6 | * {@link attachToDocument|the DOM} or transmit progress information |
| 7 | * {@link encodeEventStream|across a network}. |
| 7 | 8 | * |
| 8 | 9 | * the progress API takes advantage of the `using` keyword to make nodes |
| 9 | 10 | * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref}, |
| ... | ... | @@ -636,14 +637,28 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) { |
| 636 | 637 | export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string { |
| 637 | 638 | let out = ""; |
| 638 | 639 | for (const top of list) { |
| 639 | | if (top.passive && !hasChildren(top)) continue; |
| 640 | if (!isVisible(top)) continue; |
| 640 | 641 | out += renderAnsiMainLine(top, now, 0) + "\n"; |
| 641 | 642 | out += renderChildren(top, now, []); |
| 642 | 643 | } |
| 643 | 644 | return out.trimEnd(); |
| 644 | 645 | } |
| 645 | 646 | |
| 647 | function isVisible(node: ReadOnlyNode): boolean { |
| 648 | return !node.hidden && (!node.passive || node.children.some(isVisible)); |
| 649 | } |
| 650 | |
| 651 | function remainingSeconds(node: ReadOnlyNode): number | null { |
| 652 | const { estimatedTime } = node; |
| 653 | if (!estimatedTime) return null; |
| 654 | const ms = estimatedTime - Date.now(); |
| 655 | return ms > 1000 && !findLongerEstimate(node, estimatedTime) |
| 656 | ? Math.round(ms / 1000) |
| 657 | : null; |
| 658 | } |
| 659 | |
| 646 | 660 | const spinnerFps = 12.5; |
| 661 | const maxLogLines = 3; |
| 647 | 662 | const box = { tee: "├─ ", line: "│ ", langle: "└─ " }; |
| 648 | 663 | const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; |
| 649 | 664 | const fullBar = "█"; |
| ... | ... | @@ -651,13 +666,9 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", |
| 651 | 666 | .map((frame) => ansi.style(ansi.fgBlue, frame)); |
| 652 | 667 | |
| 653 | 668 | function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { |
| 654 | | const { text, total, showTotal, value, estimatedTime } = state; |
| 655 | | const dateNow = Date.now(); |
| 656 | | const showEstimate = estimatedTime && !findLongerEstimate(state, estimatedTime); |
| 657 | | const estimate = showEstimate && (estimatedTime > dateNow + 1000) |
| 658 | | ? ", " |
| 659 | | + string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000)) |
| 660 | | : ""; |
| 669 | const { text, total, showTotal, value } = state; |
| 670 | const remaining = remainingSeconds(state); |
| 671 | const estimate = remaining ? ", " + string.formatDurationLetters(remaining) : ""; |
| 661 | 672 | const valueFormatted = state.valueFormatter(value, total); |
| 662 | 673 | if (total && showTotal) { |
| 663 | 674 | return ansi.style( |
| ... | ... | @@ -675,30 +686,18 @@ function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { |
| 675 | 686 | + (valueFormatted ? `[${valueFormatted}] ` : "") + text); |
| 676 | 687 | } |
| 677 | 688 | |
| 678 | | function hasChildren(states: ReadOnlyNode): boolean { |
| 679 | | return states.children.some((x) => !x.hidden && (!x.passive || hasChildren(x))); |
| 680 | | } |
| 681 | | |
| 682 | | function findLongerEstimate( |
| 683 | | state: ReadOnlyNode, |
| 684 | | estimatedTime: number, |
| 685 | | ): boolean { |
| 686 | | for (const child of state.children) { |
| 687 | | if (child.hidden || (child.passive && !hasChildren(child))) continue; |
| 688 | | if (child.estimatedTime != null && child.estimatedTime > estimatedTime) { |
| 689 | | return true; |
| 690 | | } |
| 691 | | if (findLongerEstimate(child, estimatedTime)) return true; |
| 692 | | } |
| 693 | | return false; |
| 689 | function findLongerEstimate(state: ReadOnlyNode, estimatedTime: number): boolean { |
| 690 | return state.children.some((child) => |
| 691 | isVisible(child) |
| 692 | && ((child.estimatedTime ?? 0) > estimatedTime || findLongerEstimate(child, estimatedTime)) |
| 693 | ); |
| 694 | 694 | } |
| 695 | 695 | |
| 696 | 696 | function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 697 | 697 | let maxHeight = 50; // TODO: flexible layout |
| 698 | 698 | let truncated = 0; |
| 699 | 699 | let out = ""; |
| 700 | | let { children } = state; |
| 701 | | children = children.filter((x) => !x.hidden && (!x.passive || hasChildren(x))); |
| 700 | const children = state.children.filter(isVisible); |
| 702 | 701 | if (children.length === 0) return ""; |
| 703 | 702 | for (let i = 0, { length } = children; i < length; i += 1) { |
| 704 | 703 | const child = UNWRAP(children[i]); |
| ... | ... | @@ -717,7 +716,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 717 | 716 | .join("") |
| 718 | 717 | .trim(); |
| 719 | 718 | if (logLines) { |
| 720 | | for (const line of logLines.split("\n").slice(-Math.min(3, maxHeight))) { |
| 719 | for (const line of logLines.split("\n").slice(-Math.min(maxLogLines, maxHeight))) { |
| 721 | 720 | item += left + (i === length - 1 && !truncated ? " " : box.line) |
| 722 | 721 | + " " |
| 723 | 722 | + ansi.style(ansi.fgBrightBlack, ">") |
| ... | ... | @@ -829,6 +828,435 @@ function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { |
| 829 | 828 | return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children)); |
| 830 | 829 | } |
| 831 | 830 | |
| 831 | /** |
| 832 | * draw `root` into the shadow root of `host`, after the Windows 7 progress bar |
| 833 | * and busy cursor, on the animation frame after each change, until disposed. |
| 834 | * the shadow root keeps the page and the tree from styling each other, and the |
| 835 | * tree follows the page's `color-scheme`. `host` must accept a shadow root, |
| 836 | * such as a `div`; one rendered by {@linkcode formatHtml} is taken over. |
| 837 | * ```ts |
| 838 | * const root = new progress.Root(); |
| 839 | * const detach = progress.attachToDocument(root, document.getElementById("progress")!); |
| 840 | * await build(root); |
| 841 | * detach(); |
| 842 | * ``` |
| 843 | */ |
| 844 | export function attachToDocument<R, M extends Events.Map>( |
| 845 | root: Root<R, M>, |
| 846 | host: Element, |
| 847 | ): ts.Dispose { |
| 848 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); |
| 849 | shadow.innerHTML = formatShadow(""); |
| 850 | const tree = UNWRAP(shadow.lastElementChild); |
| 851 | const template = document.createElement("template"); |
| 852 | const elements = new WeakMap<ReadOnlyNode, Element>(); |
| 853 | const changedAt = new WeakMap<ReadOnlyNode, number>(); |
| 854 | const staleLogs = new Set<ReadOnlyNode>(); |
| 855 | let frame: number | null = null; |
| 856 | let tick: ts.Timer | undefined; |
| 857 | let tickIn = Infinity; |
| 858 | |
| 859 | const redraw = () => void (frame ??= requestAnimationFrame(draw)); |
| 860 | function draw() { |
| 861 | frame = null; |
| 862 | for (const node of staleLogs) { |
| 863 | const logs = elements.get(node)?.children[2]; |
| 864 | if (logs) logs.innerHTML = formatLogs(node.logs); |
| 865 | } |
| 866 | staleLogs.clear(); |
| 867 | clearTimeout(tick); |
| 868 | tickIn = Infinity; |
| 869 | sync(tree, root.active); |
| 870 | if (tickIn !== Infinity) tick = setTimeout(redraw, tickIn); |
| 871 | } |
| 872 | function sync(list: Element, nodes: readonly ReadOnlyNode[]) { |
| 873 | let next = list.firstElementChild; |
| 874 | for (const node of nodes) { |
| 875 | if (!isVisible(node)) continue; |
| 876 | // estimates show whole seconds, rounded, so each changes on a half second |
| 877 | const ms = (node.estimatedTime ?? 0) - Date.now(); |
| 878 | if (ms > 0) tickIn = Math.min(tickIn, (ms + 500) % 1000 + 5); |
| 879 | let li = elements.get(node); |
| 880 | if (li) { |
| 881 | update(li, node, changedAt); |
| 882 | } else { |
| 883 | template.innerHTML = formatNode(node, ""); |
| 884 | elements.set(node, li = UNWRAP(template.content.firstElementChild)); |
| 885 | changedAt.set(node, performance.now()); |
| 886 | } |
| 887 | if (li === next) next = li.nextElementSibling; |
| 888 | else list.insertBefore(li, next); |
| 889 | sync(UNWRAP(li.children[1]), node.children); |
| 890 | } |
| 891 | while (next) { |
| 892 | const stale = next; |
| 893 | next = next.nextElementSibling; |
| 894 | stale.remove(); |
| 895 | } |
| 896 | } |
| 897 | |
| 898 | const listeners = [ |
| 899 | root.on("node-start", redraw), |
| 900 | root.on("node-change", (node, key) => { |
| 901 | if (key === "logs") staleLogs.add(node); |
| 902 | redraw(); |
| 903 | }), |
| 904 | root.on("node-end", redraw), |
| 905 | ]; |
| 906 | draw(); |
| 907 | return ts.defer(() => { |
| 908 | for (const off of listeners) off(); |
| 909 | if (frame !== null) cancelAnimationFrame(frame); |
| 910 | clearTimeout(tick); |
| 911 | shadow.replaceChildren(); |
| 912 | }); |
| 913 | } |
| 914 | |
| 915 | /** |
| 916 | * the markup {@linkcode attachToDocument} draws for `list`, as a declarative |
| 917 | * shadow root to place inside the element it later takes over. browsers attach |
| 918 | * it only while parsing a document, such as a server rendered page. |
| 919 | */ |
| 920 | export function formatHtml(list: readonly ReadOnlyNode[]): string { |
| 921 | const format = (nodes: readonly ReadOnlyNode[]): string => |
| 922 | nodes.filter(isVisible) |
| 923 | .map((node) => formatNode(node, format(node.children))) |
| 924 | .join(""); |
| 925 | return `<template shadowrootmode="open">${formatShadow(format(list))}</template>`; |
| 926 | } |
| 927 | |
| 928 | const formatShadow = (items: string) => `<style>${css}</style><ol class=tree role=list>${items}</ol>`; |
| 929 | |
| 930 | /** the parts of a node's markup that change after it is drawn */ |
| 931 | function fields(node: ReadOnlyNode) { |
| 932 | const fraction = node.showTotal && node.total > 0 |
| 933 | ? math.clamp(node.value / node.total, 0, 1) |
| 934 | : null; |
| 935 | const remaining = remainingSeconds(node); |
| 936 | return { |
| 937 | className: fraction === null ? "spin" : fraction === 1 ? "bar full" : "bar", |
| 938 | fraction, |
| 939 | count: formatCount(node), |
| 940 | eta: remaining |
| 941 | ? string.formatDurationLetters(remaining).replace(/(?<=\D)(?=\d)/g, " ") + " left" |
| 942 | : "", |
| 943 | }; |
| 944 | } |
| 945 | |
| 946 | /** `attachToDocument` relies on this layout: row, children, logs */ |
| 947 | function formatNode(node: ReadOnlyNode, children: string): string { |
| 948 | const { className, fraction, count, eta } = fields(node); |
| 949 | const id = `n${node.key}`; |
| 950 | const value = fraction === null |
| 951 | ? "" |
| 952 | : ` aria-valuenow="${node.value}" aria-valuemax="${node.total}" style="--p:${fraction}"`; |
| 953 | return `<li class="${className}"><div class=row>` |
| 954 | + `<div class=indicator role=progressbar aria-labelledby=${id}${value}><i></i></div>` |
| 955 | + `<span class=count><b>${esc(count[0])}</b><span>${esc(count[1])}</span></span>` |
| 956 | + `<span class=eta>${eta}</span>` |
| 957 | + `<span class=label id=${id}>${esc(node.text)}</span>` |
| 958 | + `</div><ol role=list>${children}</ol><div class=logs>${formatLogs(node.logs)}</div></li>`; |
| 959 | } |
| 960 | |
| 961 | /** |
| 962 | * bring `li` from {@linkcode formatNode} up to date, except its children and |
| 963 | * logs. `changedAt` holds when each bar last moved. |
| 964 | */ |
| 965 | function update( |
| 966 | li: Element, |
| 967 | node: ReadOnlyNode, |
| 968 | changedAt: WeakMap<ReadOnlyNode, number>, |
| 969 | ) { |
| 970 | const { className, fraction, count, eta } = fields(node); |
| 971 | const [indicator, counter, estimate, label] = UNWRAP(li.firstElementChild).children; |
| 972 | const [value, total] = UNWRAP(counter).children; |
| 973 | const progressbar = UNWRAP(indicator); |
| 974 | setAttribute(li, "class", className); |
| 975 | setText(UNWRAP(value), count[0]); |
| 976 | setText(UNWRAP(total), count[1]); |
| 977 | setText(UNWRAP(estimate), eta); |
| 978 | setText(UNWRAP(label), node.text); |
| 979 | if (fraction === null) { |
| 980 | for (const name of ["aria-valuenow", "aria-valuemax", "style"]) setAttribute(progressbar, name, null); |
| 981 | return; |
| 982 | } |
| 983 | const shown = math.clamp( |
| 984 | Number(progressbar.getAttribute("aria-valuenow")) / Number(progressbar.getAttribute("aria-valuemax")), |
| 985 | 0, |
| 986 | 1, |
| 987 | ); |
| 988 | setAttribute(progressbar, "aria-valuenow", String(node.value)); |
| 989 | setAttribute(progressbar, "aria-valuemax", String(node.total)); |
| 990 | if (fraction === shown) return; |
| 991 | // a forward glide lasts as long as the last update took to arrive, so steady |
| 992 | // progress moves steadily; going back snaps, as the original does |
| 993 | const now = performance.now(); |
| 994 | const glide = fraction > shown ? Math.min(now - (changedAt.get(node) ?? now), 1000) : 0; |
| 995 | changedAt.set(node, now); |
| 996 | setAttribute(progressbar, "style", `--p:${fraction};--glide:${Math.round(glide)}ms`); |
| 997 | } |
| 998 | |
| 999 | function setAttribute(element: Element, name: string, value: string | null) { |
| 1000 | if (element.getAttribute(name) === value) return; |
| 1001 | if (value === null) element.removeAttribute(name); |
| 1002 | else element.setAttribute(name, value); |
| 1003 | } |
| 1004 | |
| 1005 | function setText(element: Element, text: string) { |
| 1006 | if (element.textContent !== text) element.textContent = text; |
| 1007 | } |
| 1008 | |
| 1009 | /** `[value, total]`, split at the formatter's slash so the total can be styled apart */ |
| 1010 | function formatCount({ value, total, showTotal, valueFormatter }: ReadOnlyNode): [string, string] { |
| 1011 | const text = valueFormatter(value, total); |
| 1012 | const slash = text.lastIndexOf("/"); |
| 1013 | if (slash === -1) return [text, ""]; |
| 1014 | return [text.slice(0, slash), showTotal ? text.slice(slash + 1) : ""]; |
| 1015 | } |
| 1016 | |
| 1017 | /** the last lines of `logs`. partial writes join into lines of their own. */ |
| 1018 | function formatLogs(logs: readonly log.Message[]): string { |
| 1019 | let start = logs.length; |
| 1020 | for (let breaks = 0; start > 0 && breaks <= maxLogLines;) { |
| 1021 | const { text, newline } = UNWRAP(logs[--start]); |
| 1022 | breaks += string.countNewlines(text) + (newline === false ? 0 : 1); |
| 1023 | } |
| 1024 | const lines: { kind: string; html: string }[] = []; |
| 1025 | let partial: { kind: string; html: string } | null = null; |
| 1026 | for (const { text, newline, level = "info", scope } of logs.slice(start)) { |
| 1027 | const style = plainStyle(); |
| 1028 | text.split("\n").forEach((part, i) => { |
| 1029 | const html = ansiLineToHtml(part, style); |
| 1030 | if (newline !== false) { |
| 1031 | // a message starts its own line, so a write's trailing newline leaves no blank one |
| 1032 | if (i === 0 && partial?.html === "") lines.pop(); |
| 1033 | const prefix = i === 0 && scope ? `<span class=scope>${esc(scope)}</span> ` : ""; |
| 1034 | lines.push({ kind: i === 0 ? level : "more", html: prefix + html }); |
| 1035 | } else if (i === 0 && partial) { |
| 1036 | partial.html += html; |
| 1037 | } else { |
| 1038 | lines.push(partial = { kind: "raw", html }); |
| 1039 | } |
| 1040 | }); |
| 1041 | if (newline !== false) partial = null; |
| 1042 | } |
| 1043 | while (lines.at(-1)?.html === "") lines.pop(); |
| 1044 | return lines.slice(-maxLogLines) |
| 1045 | .map(({ kind, html }) => `<div class=${kind}>${html}</div>`) |
| 1046 | .join(""); |
| 1047 | } |
| 1048 | |
| 1049 | interface Style { |
| 1050 | fg: string; |
| 1051 | bg: string; |
| 1052 | weight: string; |
| 1053 | italic: boolean; |
| 1054 | underline: boolean; |
| 1055 | } |
| 1056 | const plainStyle = (): Style => ({ fg: "", bg: "", weight: "", italic: false, underline: false }); |
| 1057 | |
| 1058 | const escapes = /\x1b(?:\[([\x30-\x3f]*)[\x20-\x2f]*([\x40-\x7e])|\][^\x07\x1b]*(?:\x07|\x1b\\)?|.)/g; |
| 1059 | |
| 1060 | /** one line of ansi text as html. `style` carries over, so a style may span lines. */ |
| 1061 | function ansiLineToHtml(text: string, style: Style): string { |
| 1062 | let html = ""; |
| 1063 | let last = 0; |
| 1064 | for (const match of text.matchAll(escapes)) { |
| 1065 | html += formatRun(text.slice(last, match.index), style); |
| 1066 | last = match.index + match[0].length; |
| 1067 | if (match[2] === "m") applySgr(style, (match[1] || "0").split(/[;:]/).map(Number)); |
| 1068 | } |
| 1069 | return html + formatRun(text.slice(last), style); |
| 1070 | } |
| 1071 | |
| 1072 | function formatRun(text: string, { fg, bg, weight, italic, underline }: Style) { |
| 1073 | if (!text) return ""; |
| 1074 | const css = (fg && `color:${fg};`) + (bg && `background:${bg};`) + weight |
| 1075 | + (italic ? "font-style:italic;" : "") + (underline ? "text-decoration:underline;" : ""); |
| 1076 | return css ? `<span style="${css}">${esc(text)}</span>` : esc(text); |
| 1077 | } |
| 1078 | |
| 1079 | function applySgr(style: Style, codes: number[]) { |
| 1080 | for (let i = 0; i < codes.length; i += 1) { |
| 1081 | const code = UNWRAP(codes[i]); |
| 1082 | if (code === 0) Object.assign(style, plainStyle()); |
| 1083 | else if (code === 1) style.weight = "font-weight:bold;"; |
| 1084 | else if (code === 2) style.weight = "opacity:.65;"; |
| 1085 | else if (code === 22) style.weight = ""; |
| 1086 | else if (code === 3 || code === 23) style.italic = code === 3; |
| 1087 | else if (code === 4 || code === 24) style.underline = code === 4; |
| 1088 | else if (code === 39) style.fg = ""; |
| 1089 | else if (code === 49) style.bg = ""; |
| 1090 | else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { |
| 1091 | style.fg = `var(--ansi${code % 10 + (code >= 90 ? 8 : 0)})`; |
| 1092 | } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { |
| 1093 | style.bg = `var(--ansi${code % 10 + (code >= 100 ? 8 : 0)})`; |
| 1094 | } else if (code === 38 || code === 48) { |
| 1095 | const indexed = codes[i + 1] === 5; |
| 1096 | const [r = 0, g = 0, b = 0] = codes.slice(i + 2, i + 5); |
| 1097 | const color = indexed ? color256(r) : `rgb(${r} ${g} ${b})`; |
| 1098 | i += indexed ? 2 : 4; |
| 1099 | if (code === 38) style.fg = color; |
| 1100 | else style.bg = color; |
| 1101 | } |
| 1102 | } |
| 1103 | } |
| 1104 | |
| 1105 | function color256(n: number) { |
| 1106 | if (n < 16) return `var(--ansi${n})`; |
| 1107 | if (n >= 232) return `rgb(${Array(3).fill(8 + (n - 232) * 10).join(" ")})`; |
| 1108 | n -= 16; |
| 1109 | const level = (c: number) => c && 55 + c * 40; |
| 1110 | return `rgb(${level(Math.floor(n / 36))} ${level(Math.floor(n / 6) % 6)} ${level(n % 6)})`; |
| 1111 | } |
| 1112 | |
| 1113 | const esc = string.escapeHtmlContent; |
| 1114 | |
| 1115 | /** inline svg for a background image */ |
| 1116 | const svg = (body: string) => |
| 1117 | `url("data:image/svg+xml,${ |
| 1118 | encodeURIComponent(`<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'>${body}</svg>`) |
| 1119 | }")`; |
| 1120 | |
| 1121 | // light-dark() resolves against `color-scheme`, which is inherited from the |
| 1122 | // host; `all: initial` keeps every other inherited property out. |
| 1123 | const css = /* @__PURE__ */ (() => ` |
| 1124 | :host{display:block} |
| 1125 | .tree{all:initial;display:block;color-scheme:inherit;container-type:inline-size; |
| 1126 | font:12px/23px "Segoe UI",system-ui,sans-serif;color:light-dark(#1e1e1e,#e3e5e8); |
| 1127 | --line:light-dark(#a9b3bd,#5a626c);--muted:light-dark(#56616c,#a4aeb9); |
| 1128 | --ansi0:light-dark(#000,#666);--ansi1:#cd3131;--ansi2:light-dark(#00a000,#0dbc79); |
| 1129 | --ansi3:light-dark(#949800,#e5e510);--ansi4:light-dark(#0451a5,#2472c8);--ansi5:#bc3fbc; |
| 1130 | --ansi6:light-dark(#0598bc,#11a8cd);--ansi7:light-dark(#555,#e5e5e5);--ansi8:#666; |
| 1131 | --ansi9:light-dark(#cd3131,#f14c4c);--ansi10:light-dark(#14ce14,#23d18b); |
| 1132 | --ansi11:light-dark(#b5ba00,#f5f543);--ansi12:light-dark(#0451a5,#3b8eea); |
| 1133 | --ansi13:light-dark(#bc05bc,#d670d6);--ansi14:light-dark(#0598bc,#29b8db); |
| 1134 | --ansi15:light-dark(#a5a5a5,#e5e5e5)} |
| 1135 | ol{margin:0;padding:0} |
| 1136 | li{display:flow-root;position:relative} |
| 1137 | .tree>li+li{margin-top:4px} |
| 1138 | .row{display:flex;flex-wrap:wrap;align-items:center;column-gap:6px;min-height:23px;white-space:nowrap} |
| 1139 | .label{overflow:hidden;text-overflow:ellipsis;min-width:0} |
| 1140 | .tree>li>.row>.label{font-size:13px;color:light-dark(#039,#8fb4ff)} |
| 1141 | .count,.eta{flex:none;margin:4px 0;padding:0 6px;border-radius:8px;font-size:11px;line-height:15px; |
| 1142 | font-variant-numeric:tabular-nums;color:var(--muted); |
| 1143 | background:linear-gradient(light-dark(#fdfeff,#363b43),light-dark(#edf1f5,#2b3037)); |
| 1144 | box-shadow:inset 0 0 0 1px light-dark(#d5dce4,#40474f)} |
| 1145 | .count:has(>b:empty),.eta:empty{display:none} |
| 1146 | .count>b{font-weight:600;color:light-dark(#2c3640,#d5dbe1)} |
| 1147 | .count>span:not(:empty)::before{content:"/";margin:0 .2em;opacity:.55} |
| 1148 | |
| 1149 | /* bars lead the row, wider at the top level, as in the terminal. the track is |
| 1150 | a 1px frame, 6px of gloss, a stretched body, and 5px of bottom light */ |
| 1151 | .tree>li{--bar:176} |
| 1152 | li li{--bar:96} |
| 1153 | @container (width < 400px){.tree>li{--bar:112}li li{--bar:64}} |
| 1154 | .bar>.row>.indicator{position:relative;flex:none;box-sizing:border-box;width:calc(var(--bar) * 1px); |
| 1155 | height:15px;margin:4px 0;border:1px solid transparent;border-radius:2px; |
| 1156 | background: |
| 1157 | linear-gradient(light-dark(#f3f3f3,#34383e),light-dark(#fcfcfc,#3b3f45) 1.5px, |
| 1158 | light-dark(#dbdbdb,#2f3237) 6px,light-dark(#cacaca,#26292d) 6px, |
| 1159 | light-dark(#cacaca,#26292d) calc(100% - 5px),light-dark(#d5d5d5,#2b2e33) calc(100% - 1px), |
| 1160 | light-dark(#eaeaea,#33373c)) padding-box, |
| 1161 | linear-gradient(light-dark(#b2b2b2,#4b5058) 0 1px,light-dark(#bdbdbd,#464b53) 1px, |
| 1162 | light-dark(#919191,#393d44) calc(100% - 1px),light-dark(#8c8c8c,#393d44) calc(100% - 1px)) |
| 1163 | border-box} |
| 1164 | .indicator>i{position:absolute;top:0;bottom:0;left:0;width:calc(var(--p) * 100%);overflow-x:clip; |
| 1165 | --gloss:#d2f1fc 1.5px,#addcf3 3.5px,#9ad3ef;--body:#0794d1;--rest:#1ea0d8;--edge:#035f8f; |
| 1166 | --glow:160 225 255;--shade:color-mix(in srgb,var(--edge) 40%,#0000); |
| 1167 | background: |
| 1168 | linear-gradient(90deg,#0000 1px,var(--shade) 1px,var(--shade) 5px,#0000 27px), |
| 1169 | linear-gradient(270deg,#0000 1px,var(--shade) 1px,#0000 14px), |
| 1170 | linear-gradient(var(--gloss) 6px,var(--body) 6px,var(--body) calc(100% - 5px),var(--rest)); |
| 1171 | transition:width var(--glide,0s) linear} |
| 1172 | /* a glow sweeps the fill at one speed for every bar, over the frame too, while |
| 1173 | the bottom light swells under it */ |
| 1174 | .indicator>i::before{content:"";position:absolute;left:0;right:0;bottom:0;height:5px; |
| 1175 | background:linear-gradient(#0000,rgb(var(--glow)/.6));animation:swell 2.4s ease-in-out infinite} |
| 1176 | .indicator>i::after{content:"";position:absolute;top:-1px;bottom:-1px;left:0;width:64px; |
| 1177 | background:linear-gradient(90deg,#0000,rgb(var(--glow)/.03) 8.3%,rgb(var(--glow)/.08) 16.7%, |
| 1178 | rgb(var(--glow)/.2) 25%,rgb(var(--glow)/.36) 33.3%,rgb(var(--glow)/.53) 41.7%, |
| 1179 | rgb(var(--glow)/.6),rgb(var(--glow)/.53) 58.3%,rgb(var(--glow)/.36) 66.7%, |
| 1180 | rgb(var(--glow)/.2) 75%,rgb(var(--glow)/.08) 83.3%,rgb(var(--glow)/.03) 91.7%,#0000); |
| 1181 | animation:glow 2.4s linear infinite} |
| 1182 | @keyframes glow{from{translate:-100%}50%,to{translate:176px}} |
| 1183 | @keyframes swell{from{opacity:.4}25%{opacity:1}65%,88%{opacity:0}to{opacity:.4}} |
| 1184 | :is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::before, |
| 1185 | :is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::after{content:none} |
| 1186 | li:has(>.logs>.warn)>.row>.indicator>i{ |
| 1187 | --gloss:#fffdcd 1.5px,#fffba1 3.5px,#fffa8b;--body:#d2ca00;--rest:#fef400;--edge:#8f8600} |
| 1188 | li:has(>.logs>.error)>.row>.indicator>i{ |
| 1189 | --gloss:#ffcdcd 1.5px,#ffa1a1 3.5px,#ff8b8b;--body:#d20000;--rest:#fe0000;--edge:#8f0000} |
| 1190 | |
| 1191 | /* the busy ring leads a top-level row, lit from above, with a highlight |
| 1192 | running clockwise; below the top level the terminal shows no spinner */ |
| 1193 | .spin>.row>.indicator{position:relative;flex:none;width:15px;height:15px;margin:4px 0;border-radius:50%; |
| 1194 | background:conic-gradient(#9bc6da,#8dc2d0 30deg,#6daebd 60deg,#2b8982 90deg,#319f9d 120deg, |
| 1195 | #4dc7bb 150deg,#5ecfc5 180deg,#4fc6bb 210deg,#2da09e 240deg,#238382 270deg,#6db2bb 300deg, |
| 1196 | #91c5d1 330deg,#9bc6da); |
| 1197 | mask:radial-gradient(closest-side,#0000 61%,#000 67%)} |
| 1198 | li li.spin>.row>.indicator{display:none} |
| 1199 | .spin>.row>.indicator::before,.spin>.row>.indicator::after{content:"";position:absolute;inset:0;border-radius:50%} |
| 1200 | .spin>.row>.indicator::before{animation:spin .9s linear infinite; |
| 1201 | background:conic-gradient(from -150deg,#0000,#2fd4dc99 40deg,#4fe6eae6 90deg,#8ff3f4 125deg, |
| 1202 | #e8fcfd 150deg,#b4eef0b3 162deg,#0000 180deg)} |
| 1203 | .spin>.row>.indicator::after{background:radial-gradient(closest-side,#00323c73 64%,#00323c00 76%, |
| 1204 | #fff3 86%,#00323c00 92%,#00323c80)} |
| 1205 | .spin>.row>.indicator>i{display:none} |
| 1206 | @keyframes spin{to{rotate:1turn}} |
| 1207 | |
| 1208 | /* tree lines: a node's children hang from the start of its row, and each child |
| 1209 | is drawn with the terminal's ├─, │ and └─ */ |
| 1210 | li li{padding-left:22px} |
| 1211 | li>ol{position:relative} |
| 1212 | li>ol:not(:empty)::before{content:"";position:absolute;left:7px;top:-4px;height:5px; |
| 1213 | border-left:1px solid var(--line)} |
| 1214 | li li::before,li li::after{content:"";position:absolute;left:7px;border:0 solid var(--line)} |
| 1215 | li li::before{top:0;bottom:-1px;border-left-width:1px} |
| 1216 | li li::after{top:11px;width:11px;border-top-width:1px} |
| 1217 | li li:last-child::before{bottom:auto;height:12px} |
| 1218 | |
| 1219 | .logs{margin:1px 0 4px;padding:3px 8px;border-radius:4px; |
| 1220 | background:light-dark(#f3f5f8,#24282e);box-shadow:inset 0 0 0 1px light-dark(#e1e6ec,#31363d); |
| 1221 | font:11px/16px ui-monospace,"Cascadia Mono",Consolas,"SF Mono",Menlo,monospace;color:var(--muted)} |
| 1222 | .logs:empty{display:none} |
| 1223 | .logs>div{overflow:hidden;text-overflow:ellipsis;white-space:pre} |
| 1224 | .logs>.more{padding-left:16px} |
| 1225 | .logs>.debug{opacity:.7} |
| 1226 | .logs>:is(.info,.warn,.error)::before{content:"";display:inline-block;width:11px;height:11px; |
| 1227 | margin-right:5px;vertical-align:-2px;background:center/contain no-repeat} |
| 1228 | .logs>.info::before{background-image:${ |
| 1229 | svg( |
| 1230 | "<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'/>", |
| 1231 | ) |
| 1232 | }} |
| 1233 | .logs>.warn::before{background-image:${ |
| 1234 | svg( |
| 1235 | "<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'/>", |
| 1236 | ) |
| 1237 | }} |
| 1238 | .logs>.error::before{background-image:${ |
| 1239 | svg( |
| 1240 | "<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'/>", |
| 1241 | ) |
| 1242 | }} |
| 1243 | .scope{color:light-dark(#7a6a9e,#b5a6d8)} |
| 1244 | |
| 1245 | @media (prefers-reduced-motion:reduce){ |
| 1246 | .indicator>i{transition:none} |
| 1247 | .indicator>i::before,.indicator>i::after{content:none} |
| 1248 | .spin>.row>.indicator::before{animation:pulse 1.8s ease-in-out infinite alternate} |
| 1249 | } |
| 1250 | @keyframes pulse{from{opacity:.25}} |
| 1251 | @media (forced-colors:active){ |
| 1252 | .indicator{forced-color-adjust:none} |
| 1253 | .bar>.row>.indicator{background:Canvas;border-color:CanvasText} |
| 1254 | .indicator>i{background:Highlight} |
| 1255 | .indicator>i::before,.indicator>i::after{content:none} |
| 1256 | .spin>.row>.indicator{background:Highlight} |
| 1257 | } |
| 1258 | `)(); |
| 1259 | |
| 832 | 1260 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); |
| 833 | 1261 | |
| 834 | 1262 | /** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */ |
| ... | ... | @@ -1524,6 +1952,7 @@ function writeStreamEvent( |
| 1524 | 1952 | if (level === -1) level = 0; |
| 1525 | 1953 | w.u8( |
| 1526 | 1954 | level |
| 1955 | + (msg.newline === false ? 1 << 4 : 0) |
| 1527 | 1956 | + (msg.scope ? 1 << 5 : 0) |
| 1528 | 1957 | + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) |
| 1529 | 1958 | + (msg.custom !== undefined ? 1 << 7 : 0), |
| ... | ... | @@ -1543,7 +1972,6 @@ function writeStreamEvent( |
| 1543 | 1972 | if (msg.custom !== undefined) { |
| 1544 | 1973 | w.stringWithLength(JSON.stringify(msg.custom)); |
| 1545 | 1974 | } |
| 1546 | | // TODO: serialize newline attribute |
| 1547 | 1975 | } |
| 1548 | 1976 | } |
| 1549 | 1977 | if (childrenSet !== undefined) { |
| ... | ... | @@ -1655,6 +2083,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { |
| 1655 | 2083 | // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as |
| 1656 | 2084 | // `msgFlags && true`, which fabricated scope/stack/custom reads for |
| 1657 | 2085 | // any message with a non-zero level and desynced the entire stream |
| 2086 | const partial = (msgFlags & (1 << 4)) !== 0; |
| 1658 | 2087 | const hasScope = (msgFlags & (1 << 5)) !== 0; |
| 1659 | 2088 | const hasStack = (msgFlags & (1 << 6)) !== 0; |
| 1660 | 2089 | const hasCustom = (msgFlags & (1 << 7)) !== 0; |
| ... | ... | @@ -1681,7 +2110,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { |
| 1681 | 2110 | const custom = hasCustom |
| 1682 | 2111 | ? JSON.parse(await r.stringWithLength()) |
| 1683 | 2112 | : undefined; |
| 1684 | | l.push({ level, text, time, scope, stack, custom }); |
| 2113 | l.push({ level, text, time, scope, stack, custom, newline: partial ? false : undefined }); |
| 1685 | 2114 | } |
| 1686 | 2115 | } |
| 1687 | 2116 | let c: undefined | EncodedKey[] = undefined; |
| ... | ... | @@ -1945,6 +2374,7 @@ import { Events } from "./Events.ts"; |
| 1945 | 2374 | import * as exceptions from "./exception.ts"; |
| 1946 | 2375 | import * as log from "./log.ts"; |
| 1947 | 2376 | import * as stack from "./log/stack.ts"; |
| 2377 | import * as math from "./math.ts"; |
| 1948 | 2378 | import * as node from "./node.ts"; |
| 1949 | 2379 | import * as stream from "./stream.ts"; |
| 1950 | 2380 | import * as string from "./string.ts"; |