| ... | @@ -2,8 +2,9 @@ | ... | @@ -2,8 +2,9 @@ |
| 2 | * a progress reporting API that allows creating CLI spinners and progress bars | 2 | * a progress reporting API that allows creating CLI spinners and progress bars |
| 3 | * that can be easily nested to visualize complex, parallel work. in addition | 3 | * that can be easily nested to visualize complex, parallel work. in addition |
| 4 | * to providing rich feedback in the terminal, a headless {@linkcode Root} node | 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} | 5 | * can be constructed, which can be used to bind to |
| 6 | * or transmit progress information {@link encodeStream|across a network}. | 6 | * {@link attachToDocument|the DOM} or transmit progress information |
| | 7 | * {@link encodeEventStream|across a network}. |
| 7 | * | 8 | * |
| 8 | * the progress API takes advantage of the `using` keyword to make nodes | 9 | * the progress API takes advantage of the `using` keyword to make nodes |
| 9 | * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref}, | 10 | * 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,14 +637,28 @@ function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) { |
| 636 | export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string { | 637 | export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string { |
| 637 | let out = ""; | 638 | let out = ""; |
| 638 | for (const top of list) { | 639 | for (const top of list) { |
| 639 | if (top.passive && !hasChildren(top)) continue; | 640 | if (!isVisible(top)) continue; |
| 640 | out += renderAnsiMainLine(top, now, 0) + "\n"; | 641 | out += renderAnsiMainLine(top, now, 0) + "\n"; |
| 641 | out += renderChildren(top, now, []); | 642 | out += renderChildren(top, now, []); |
| 642 | } | 643 | } |
| 643 | return out.trimEnd(); | 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 | const spinnerFps = 12.5; | 660 | const spinnerFps = 12.5; |
| | 661 | const maxLogLines = 3; |
| 647 | const box = { tee: "├─ ", line: "│ ", langle: "└─ " }; | 662 | const box = { tee: "├─ ", line: "│ ", langle: "└─ " }; |
| 648 | const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; | 663 | const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; |
| 649 | const fullBar = "█"; | 664 | const fullBar = "█"; |
| ... | @@ -651,13 +666,9 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", | ... | @@ -651,13 +666,9 @@ const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", |
| 651 | .map((frame) => ansi.style(ansi.fgBlue, frame)); | 666 | .map((frame) => ansi.style(ansi.fgBlue, frame)); |
| 652 | | 667 | |
| 653 | function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { | 668 | function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { |
| 654 | const { text, total, showTotal, value, estimatedTime } = state; | 669 | const { text, total, showTotal, value } = state; |
| 655 | const dateNow = Date.now(); | 670 | const remaining = remainingSeconds(state); |
| 656 | const showEstimate = estimatedTime && !findLongerEstimate(state, estimatedTime); | 671 | const estimate = remaining ? ", " + string.formatDurationLetters(remaining) : ""; |
| 657 | const estimate = showEstimate && (estimatedTime > dateNow + 1000) | | |
| 658 | ? ", " | | |
| 659 | + string.formatDurationLetters(Math.round((estimatedTime - dateNow) / 1000)) | | |
| 660 | : ""; | | |
| 661 | const valueFormatted = state.valueFormatter(value, total); | 672 | const valueFormatted = state.valueFormatter(value, total); |
| 662 | if (total && showTotal) { | 673 | if (total && showTotal) { |
| 663 | return ansi.style( | 674 | return ansi.style( |
| ... | @@ -675,30 +686,18 @@ function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { | ... | @@ -675,30 +686,18 @@ function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { |
| 675 | + (valueFormatted ? `[${valueFormatted}] ` : "") + text); | 686 | + (valueFormatted ? `[${valueFormatted}] ` : "") + text); |
| 676 | } | 687 | } |
| 677 | | 688 | |
| 678 | function hasChildren(states: ReadOnlyNode): boolean { | 689 | function findLongerEstimate(state: ReadOnlyNode, estimatedTime: number): boolean { |
| 679 | return states.children.some((x) => !x.hidden && (!x.passive || hasChildren(x))); | 690 | return state.children.some((child) => |
| 680 | } | 691 | isVisible(child) |
| 681 | | 692 | && ((child.estimatedTime ?? 0) > estimatedTime || findLongerEstimate(child, estimatedTime)) |
| 682 | function findLongerEstimate( | 693 | ); |
| 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; | | |
| 694 | } | 694 | } |
| 695 | | 695 | |
| 696 | function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { | 696 | function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 697 | let maxHeight = 50; // TODO: flexible layout | 697 | let maxHeight = 50; // TODO: flexible layout |
| 698 | let truncated = 0; | 698 | let truncated = 0; |
| 699 | let out = ""; | 699 | let out = ""; |
| 700 | let { children } = state; | 700 | const children = state.children.filter(isVisible); |
| 701 | children = children.filter((x) => !x.hidden && (!x.passive || hasChildren(x))); | | |
| 702 | if (children.length === 0) return ""; | 701 | if (children.length === 0) return ""; |
| 703 | for (let i = 0, { length } = children; i < length; i += 1) { | 702 | for (let i = 0, { length } = children; i < length; i += 1) { |
| 704 | const child = UNWRAP(children[i]); | 703 | const child = UNWRAP(children[i]); |
| ... | @@ -717,7 +716,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { | ... | @@ -717,7 +716,7 @@ function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { |
| 717 | .join("") | 716 | .join("") |
| 718 | .trim(); | 717 | .trim(); |
| 719 | if (logLines) { | 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 | item += left + (i === length - 1 && !truncated ? " " : box.line) | 720 | item += left + (i === length - 1 && !truncated ? " " : box.line) |
| 722 | + " " | 721 | + " " |
| 723 | + ansi.style(ansi.fgBrightBlack, ">") | 722 | + ansi.style(ansi.fgBrightBlack, ">") |
| ... | @@ -829,6 +828,435 @@ function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { | ... | @@ -829,6 +828,435 @@ function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { |
| 829 | return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children)); | 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 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); | 1260 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); |
| 833 | | 1261 | |
| 834 | /** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */ | 1262 | /** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */ |
| ... | @@ -1524,6 +1952,7 @@ function writeStreamEvent( | ... | @@ -1524,6 +1952,7 @@ function writeStreamEvent( |
| 1524 | if (level === -1) level = 0; | 1952 | if (level === -1) level = 0; |
| 1525 | w.u8( | 1953 | w.u8( |
| 1526 | level | 1954 | level |
| | 1955 | + (msg.newline === false ? 1 << 4 : 0) |
| 1527 | + (msg.scope ? 1 << 5 : 0) | 1956 | + (msg.scope ? 1 << 5 : 0) |
| 1528 | + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) | 1957 | + (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) |
| 1529 | + (msg.custom !== undefined ? 1 << 7 : 0), | 1958 | + (msg.custom !== undefined ? 1 << 7 : 0), |
| ... | @@ -1543,7 +1972,6 @@ function writeStreamEvent( | ... | @@ -1543,7 +1972,6 @@ function writeStreamEvent( |
| 1543 | if (msg.custom !== undefined) { | 1972 | if (msg.custom !== undefined) { |
| 1544 | w.stringWithLength(JSON.stringify(msg.custom)); | 1973 | w.stringWithLength(JSON.stringify(msg.custom)); |
| 1545 | } | 1974 | } |
| 1546 | // TODO: serialize newline attribute | | |
| 1547 | } | 1975 | } |
| 1548 | } | 1976 | } |
| 1549 | if (childrenSet !== undefined) { | 1977 | if (childrenSet !== undefined) { |
| ... | @@ -1655,6 +2083,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { | ... | @@ -1655,6 +2083,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { |
| 1655 | // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as | 2083 | // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as |
| 1656 | // `msgFlags && true`, which fabricated scope/stack/custom reads for | 2084 | // `msgFlags && true`, which fabricated scope/stack/custom reads for |
| 1657 | // any message with a non-zero level and desynced the entire stream | 2085 | // any message with a non-zero level and desynced the entire stream |
| | 2086 | const partial = (msgFlags & (1 << 4)) !== 0; |
| 1658 | const hasScope = (msgFlags & (1 << 5)) !== 0; | 2087 | const hasScope = (msgFlags & (1 << 5)) !== 0; |
| 1659 | const hasStack = (msgFlags & (1 << 6)) !== 0; | 2088 | const hasStack = (msgFlags & (1 << 6)) !== 0; |
| 1660 | const hasCustom = (msgFlags & (1 << 7)) !== 0; | 2089 | const hasCustom = (msgFlags & (1 << 7)) !== 0; |
| ... | @@ -1681,7 +2110,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { | ... | @@ -1681,7 +2110,7 @@ async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { |
| 1681 | const custom = hasCustom | 2110 | const custom = hasCustom |
| 1682 | ? JSON.parse(await r.stringWithLength()) | 2111 | ? JSON.parse(await r.stringWithLength()) |
| 1683 | : undefined; | 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 | let c: undefined | EncodedKey[] = undefined; | 2116 | let c: undefined | EncodedKey[] = undefined; |
| ... | @@ -1945,6 +2374,7 @@ import { Events } from "./Events.ts"; | ... | @@ -1945,6 +2374,7 @@ import { Events } from "./Events.ts"; |
| 1945 | import * as exceptions from "./exception.ts"; | 2374 | import * as exceptions from "./exception.ts"; |
| 1946 | import * as log from "./log.ts"; | 2375 | import * as log from "./log.ts"; |
| 1947 | import * as stack from "./log/stack.ts"; | 2376 | import * as stack from "./log/stack.ts"; |
| | 2377 | import * as math from "./math.ts"; |
| 1948 | import * as node from "./node.ts"; | 2378 | import * as node from "./node.ts"; |
| 1949 | import * as stream from "./stream.ts"; | 2379 | import * as stream from "./stream.ts"; |
| 1950 | import * as string from "./string.ts"; | 2380 | import * as string from "./string.ts"; |