effective-progress 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import type { CellWrapMode } from "./types";
2
+
3
+ const ESC = String.fromCharCode(27);
4
+ const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;?]*[ -/]*[@-~]`, "g");
5
+ const RESET_ANSI = "\x1b[0m";
6
+
7
+ interface AnsiToken {
8
+ readonly kind: "ansi" | "char";
9
+ readonly value: string;
10
+ }
11
+
12
+ const textWidth = (text: string): number => Array.from(text).length;
13
+
14
+ export const stripAnsi = (text: string): string => text.replace(ANSI_PATTERN, "");
15
+
16
+ export const visibleWidth = (text: string): number => textWidth(stripAnsi(text));
17
+
18
+ const tokenizeAnsi = (text: string): ReadonlyArray<AnsiToken> => {
19
+ const tokens: Array<AnsiToken> = [];
20
+ let i = 0;
21
+
22
+ while (i < text.length) {
23
+ if (text[i] === "\x1b" && text[i + 1] === "[") {
24
+ let j = i + 2;
25
+ while (j < text.length) {
26
+ const code = text.charCodeAt(j);
27
+ j += 1;
28
+ if (code >= 0x40 && code <= 0x7e) {
29
+ break;
30
+ }
31
+ }
32
+ tokens.push({ kind: "ansi", value: text.slice(i, j) });
33
+ i = j;
34
+ continue;
35
+ }
36
+
37
+ const codePoint = text.codePointAt(i);
38
+ if (codePoint === undefined) {
39
+ break;
40
+ }
41
+
42
+ const char = String.fromCodePoint(codePoint);
43
+ tokens.push({ kind: "char", value: char });
44
+ i += char.length;
45
+ }
46
+
47
+ return tokens;
48
+ };
49
+
50
+ const fitPlainText = (text: string, width: number, wrapMode: CellWrapMode): string => {
51
+ const target = Math.max(0, Math.floor(width));
52
+ if (target <= 0) {
53
+ return "";
54
+ }
55
+
56
+ const chars = Array.from(text);
57
+ let result = text;
58
+
59
+ if (chars.length > target) {
60
+ if (wrapMode === "ellipsis") {
61
+ result = target === 1 ? "…" : `${chars.slice(0, target - 1).join("")}…`;
62
+ } else {
63
+ result = chars.slice(0, target).join("");
64
+ }
65
+ }
66
+
67
+ const currentWidth = textWidth(result);
68
+ if (currentWidth < target) {
69
+ result += " ".repeat(target - currentWidth);
70
+ }
71
+
72
+ return result;
73
+ };
74
+
75
+ const fitAnsiText = (text: string, width: number, wrapMode: CellWrapMode): string => {
76
+ const target = Math.max(0, Math.floor(width));
77
+ if (target <= 0) {
78
+ return "";
79
+ }
80
+
81
+ const tokens = tokenizeAnsi(text);
82
+ const totalVisible = tokens.reduce((sum, token) => sum + (token.kind === "char" ? 1 : 0), 0);
83
+
84
+ if (totalVisible <= target) {
85
+ const padBy = target - totalVisible;
86
+ return padBy > 0 ? `${text}${" ".repeat(padBy)}` : text;
87
+ }
88
+
89
+ const keepVisible = wrapMode === "ellipsis" ? Math.max(0, target - 1) : target;
90
+ let visible = 0;
91
+ let sawAnsi = false;
92
+ let output = "";
93
+
94
+ for (const token of tokens) {
95
+ if (token.kind === "ansi") {
96
+ sawAnsi = true;
97
+ if (keepVisible > 0 && visible <= keepVisible) {
98
+ output += token.value;
99
+ }
100
+ continue;
101
+ }
102
+
103
+ if (visible >= keepVisible) {
104
+ break;
105
+ }
106
+
107
+ output += token.value;
108
+ visible += 1;
109
+ }
110
+
111
+ if (wrapMode === "ellipsis" && target > 0) {
112
+ output += "…";
113
+ }
114
+
115
+ if (sawAnsi && !output.endsWith(RESET_ANSI)) {
116
+ output += RESET_ANSI;
117
+ }
118
+
119
+ const padBy = target - visibleWidth(output);
120
+ if (padBy > 0) {
121
+ output += " ".repeat(padBy);
122
+ }
123
+
124
+ return output;
125
+ };
126
+
127
+ export const fitRenderedText = (
128
+ text: string,
129
+ width: number,
130
+ wrapMode: CellWrapMode,
131
+ isTTY: boolean,
132
+ ): string => {
133
+ const raw = isTTY ? text : stripAnsi(text);
134
+ if (!isTTY || raw.indexOf("\x1b[") === -1) {
135
+ return fitPlainText(raw, width, wrapMode);
136
+ }
137
+ return fitAnsiText(raw, width, wrapMode);
138
+ };
@@ -0,0 +1,68 @@
1
+ import type { TaskSnapshot } from "../types";
2
+ import type { TaskTreeInfo } from "./types";
3
+
4
+ export interface OrderedTreeTask {
5
+ readonly snapshot: TaskSnapshot;
6
+ readonly depth: number;
7
+ }
8
+
9
+ const treeAncestorPrefix = (tree: TaskTreeInfo): string =>
10
+ tree.ancestorHasNextSibling
11
+ .slice(1)
12
+ .map((hasNext) => (hasNext ? "│ " : " "))
13
+ .join("");
14
+
15
+ export const renderTreePrefix = (tree: TaskTreeInfo): string => {
16
+ if (tree.depth <= 0) {
17
+ return "";
18
+ }
19
+
20
+ const ancestor = treeAncestorPrefix(tree);
21
+ return `${ancestor}${tree.hasNextSibling ? "├─ " : "└─ "}`;
22
+ };
23
+
24
+ export const computeTreeInfo = (
25
+ ordered: ReadonlyArray<OrderedTreeTask>,
26
+ ): ReadonlyArray<OrderedTreeTask & { readonly tree: TaskTreeInfo }> => {
27
+ const hasNextSiblingByIndex: Array<boolean> = Array.from({ length: ordered.length }, () => false);
28
+
29
+ for (let i = 0; i < ordered.length; i++) {
30
+ const depth = ordered[i]!.depth;
31
+ for (let j = i + 1; j < ordered.length; j++) {
32
+ const candidateDepth = ordered[j]!.depth;
33
+ if (candidateDepth < depth) {
34
+ break;
35
+ }
36
+ if (candidateDepth === depth) {
37
+ hasNextSiblingByIndex[i] = true;
38
+ break;
39
+ }
40
+ }
41
+ }
42
+
43
+ const ancestorStateByDepth: Array<boolean> = [];
44
+
45
+ return ordered.map((entry, index) => {
46
+ const depth = entry.depth;
47
+ ancestorStateByDepth.length = depth;
48
+
49
+ const hasChildren =
50
+ index + 1 < ordered.length &&
51
+ ordered[index + 1] !== undefined &&
52
+ ordered[index + 1]!.depth > depth;
53
+
54
+ const tree: TaskTreeInfo = {
55
+ depth,
56
+ hasNextSibling: hasNextSiblingByIndex[index] ?? false,
57
+ hasChildren,
58
+ ancestorHasNextSibling: [...ancestorStateByDepth],
59
+ };
60
+
61
+ ancestorStateByDepth[depth] = hasNextSiblingByIndex[index] ?? false;
62
+
63
+ return {
64
+ ...entry,
65
+ tree,
66
+ };
67
+ });
68
+ };
@@ -0,0 +1,67 @@
1
+ import type { TaskSnapshot } from "../types";
2
+
3
+ /**
4
+ * Defines how a column claims width during the shrink/fit stage.
5
+ */
6
+ export type ColumnTrack =
7
+ | {
8
+ readonly _tag: "Auto";
9
+ }
10
+ | {
11
+ readonly _tag: "Fixed";
12
+ readonly width: number;
13
+ }
14
+ | {
15
+ readonly _tag: "Fraction";
16
+ readonly weight: number;
17
+ };
18
+
19
+ export const Track = {
20
+ auto: (): ColumnTrack => ({ _tag: "Auto" }),
21
+ fixed: (width: number): ColumnTrack => ({
22
+ _tag: "Fixed",
23
+ width: Math.max(0, Math.floor(width)),
24
+ }),
25
+ fr: (weight = 1): ColumnTrack => ({
26
+ _tag: "Fraction",
27
+ weight: Math.max(0.001, weight),
28
+ }),
29
+ } as const;
30
+
31
+ /**
32
+ * Tree relationship metadata for rendering connectors.
33
+ */
34
+ export interface TaskTreeInfo {
35
+ readonly depth: number;
36
+ readonly hasNextSibling: boolean;
37
+ readonly hasChildren: boolean;
38
+ readonly ancestorHasNextSibling: ReadonlyArray<boolean>;
39
+ }
40
+
41
+ export type CellWrapMode = "truncate" | "ellipsis";
42
+
43
+ export interface ProgressColumnContext {
44
+ readonly task: TaskSnapshot;
45
+ readonly depth: number;
46
+ readonly tree: TaskTreeInfo;
47
+ readonly now: number;
48
+ readonly tick: number;
49
+ readonly isTTY: boolean;
50
+ }
51
+
52
+ export interface ProgressColumnVariant {
53
+ readonly measure?: (context: ProgressColumnContext) => number;
54
+ readonly render: (context: ProgressColumnContext, width: number) => string;
55
+ }
56
+
57
+ export interface ProgressColumn {
58
+ readonly id: string;
59
+ readonly track?: ColumnTrack;
60
+ readonly minWidth?: number;
61
+ readonly maxWidth?: number;
62
+ readonly collapsePriority?: number;
63
+ readonly wrapMode?: CellWrapMode;
64
+ readonly measure?: (context: ProgressColumnContext) => number;
65
+ readonly render: (context: ProgressColumnContext, width: number) => string;
66
+ readonly variants?: (context: ProgressColumnContext) => ReadonlyArray<ProgressColumnVariant>;
67
+ }