@heinrichb/console-toolkit 1.0.3 → 1.0.5
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.
- package/README.md +162 -2
- package/dist/components/progress.d.ts +75 -0
- package/dist/components/progress.js +43 -0
- package/dist/components/progress.test.d.ts +1 -0
- package/dist/components/progress.test.js +101 -0
- package/dist/components/spinner.d.ts +36 -0
- package/dist/components/spinner.js +33 -0
- package/dist/components/spinner.test.d.ts +1 -0
- package/dist/components/spinner.test.js +53 -0
- package/dist/core/layout.d.ts +25 -0
- package/dist/core/layout.js +57 -0
- package/dist/core/layout.test.d.ts +1 -0
- package/dist/core/layout.test.js +41 -0
- package/dist/core/printer.d.ts +20 -0
- package/dist/core/printer.js +41 -0
- package/dist/core/printer.test.d.ts +1 -0
- package/dist/core/printer.test.js +36 -0
- package/dist/core/style.d.ts +51 -0
- package/dist/core/style.js +127 -0
- package/dist/core/style.test.d.ts +1 -0
- package/dist/core/style.test.js +53 -0
- package/dist/core/types.d.ts +42 -0
- package/dist/core/types.js +4 -0
- package/dist/core/utils.d.ts +25 -0
- package/dist/core/utils.js +39 -0
- package/dist/core/utils.test.d.ts +1 -0
- package/dist/core/utils.test.js +25 -0
- package/dist/demo.d.ts +0 -4
- package/dist/demo.js +119 -15
- package/dist/index.d.ts +8 -99
- package/dist/index.js +14 -234
- package/dist/index.test.js +26 -6
- package/dist/presets/ascii.d.ts +5 -0
- package/dist/presets/ascii.js +33 -0
- package/dist/presets/ascii.test.d.ts +1 -0
- package/dist/presets/ascii.test.js +10 -0
- package/dist/spinner.d.ts +36 -0
- package/dist/spinner.js +33 -0
- package/dist/spinner.test.d.ts +1 -0
- package/dist/spinner.test.js +53 -0
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -1,2 +1,162 @@
|
|
|
1
|
-
#
|
|
2
|
-
|
|
1
|
+
# 🎨 Console Toolkit
|
|
2
|
+
|
|
3
|
+
**@heinrichb/console-toolkit** is a powerful, lightweight TypeScript library for creating beautiful, interactive, and structured command-line interfaces. From simple colored text to complex multi-column layouts and live-updating progress bars, this toolkit has everything you need to elevate your CLI experience.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 🚀 Features
|
|
8
|
+
|
|
9
|
+
- **🌈 Rich Styling:** Support for standard ANSI colors, true-color Hex codes, and text modifiers (bold, dim, italic, etc.).
|
|
10
|
+
- **✨ Gradients:** Easy-to-use linear gradients for text and backgrounds.
|
|
11
|
+
- **live-updating:** Built-in support for live-updating displays (perfect for spinners and progress bars).
|
|
12
|
+
- **📐 Flexible Layouts:** powerful grid system for multi-column layouts with automatic padding and alignment.
|
|
13
|
+
- **🧩 Components:** Pre-built, customizable components like **Progress Bars** and **Spinners**.
|
|
14
|
+
- **🐉 Presets:** Fun ASCII art presets (like dragons!) to spice up your output.
|
|
15
|
+
- **TypeScript First:** Fully typed for a great developer experience.
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## 📦 Installation
|
|
20
|
+
|
|
21
|
+
This library is designed for use with **Bun** or **Node.js**.
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
bun add @heinrichb/console-toolkit
|
|
25
|
+
# or
|
|
26
|
+
npm install @heinrichb/console-toolkit
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## ⚡ Quick Start
|
|
32
|
+
|
|
33
|
+
Get up and running in seconds:
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { Printer } from "@heinrichb/console-toolkit";
|
|
37
|
+
|
|
38
|
+
const printer = new Printer();
|
|
39
|
+
|
|
40
|
+
printer.print({
|
|
41
|
+
lines: [
|
|
42
|
+
{
|
|
43
|
+
segments: [
|
|
44
|
+
{ text: "Hello, ", style: { color: "blue", modifiers: ["bold"] } },
|
|
45
|
+
{ text: "World!", style: { color: "#10B981", modifiers: ["italic"] } } // Hex color support!
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 🎨 Styling
|
|
55
|
+
|
|
56
|
+
We support a flexible styling system that works with both standard terminal colors and full RGB Hex codes.
|
|
57
|
+
|
|
58
|
+
### Basic Colors & Modifiers
|
|
59
|
+
|
|
60
|
+
```typescript
|
|
61
|
+
import { PrintStyle } from "@heinrichb/console-toolkit";
|
|
62
|
+
|
|
63
|
+
const myStyle: PrintStyle = {
|
|
64
|
+
color: "red", // Standard color
|
|
65
|
+
modifiers: ["bold", "underline"]
|
|
66
|
+
};
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### Hex Colors & Gradients
|
|
70
|
+
|
|
71
|
+
You can use any hex color string. For gradients, simply provide an array of colors!
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
const gradientStyle: PrintStyle = {
|
|
75
|
+
// Creates a gradient from Red to Blue
|
|
76
|
+
color: ["#EF4444", "#3B82F6"]
|
|
77
|
+
};
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 📐 Layouts
|
|
83
|
+
|
|
84
|
+
Creating multi-column layouts is a breeze.
|
|
85
|
+
|
|
86
|
+
```typescript
|
|
87
|
+
import { printColumns } from "@heinrichb/console-toolkit";
|
|
88
|
+
|
|
89
|
+
printColumns(
|
|
90
|
+
[
|
|
91
|
+
[
|
|
92
|
+
// Column 1
|
|
93
|
+
{ segments: [{ text: "Item 1" }] },
|
|
94
|
+
{ segments: [{ text: "Item 2" }] }
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
// Column 2
|
|
98
|
+
{ segments: [{ text: "Description 1", style: { color: "gray" } }] },
|
|
99
|
+
{ segments: [{ text: "Description 2", style: { color: "gray" } }] }
|
|
100
|
+
]
|
|
101
|
+
],
|
|
102
|
+
{ separator: " | " }
|
|
103
|
+
);
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## 🧩 Components
|
|
109
|
+
|
|
110
|
+
### Progress Bars
|
|
111
|
+
|
|
112
|
+
Create customizable progress bars with ease.
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
import { createProgressBar, Printer } from "@heinrichb/console-toolkit";
|
|
116
|
+
|
|
117
|
+
const printer = new Printer({ live: true });
|
|
118
|
+
const bar = createProgressBar({
|
|
119
|
+
progress: 0.75,
|
|
120
|
+
width: 30,
|
|
121
|
+
fillStyle: { color: "green" },
|
|
122
|
+
emptyStyle: { color: "gray" }
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
printer.print({ lines: [bar] });
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Spinners
|
|
129
|
+
|
|
130
|
+
Add activity indicators to your long-running tasks.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { Spinner, SPINNERS, Printer } from "@heinrichb/console-toolkit";
|
|
134
|
+
|
|
135
|
+
const spinner = new Spinner({ frames: SPINNERS.dots });
|
|
136
|
+
const printer = new Printer({ live: true });
|
|
137
|
+
|
|
138
|
+
// In your loop:
|
|
139
|
+
printer.print({
|
|
140
|
+
lines: [
|
|
141
|
+
{
|
|
142
|
+
segments: [{ text: spinner.getFrame(), style: { color: "cyan" } }]
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
---
|
|
149
|
+
|
|
150
|
+
## 📚 Detailed Documentation
|
|
151
|
+
|
|
152
|
+
For more in-depth information on specific parts of the library, check out the detailed guides below:
|
|
153
|
+
|
|
154
|
+
- **[Core Engine & Styling](src/core/README.md):** Deep dive into `Printer`, `PrintBlock`, `PrintStyle`, and advanced layout techniques.
|
|
155
|
+
- **[Components](src/components/README.md):** Full API reference for `ProgressBar`, `Spinner`, and how to build your own components.
|
|
156
|
+
- **[Presets](src/presets/README.md):** Explore available ASCII art and other presets.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 📄 License
|
|
161
|
+
|
|
162
|
+
MIT © Brennen Heinrich
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { StyledLine, Style } from "../core/types";
|
|
2
|
+
export interface ProgressBarOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Progress value between 0.0 and 1.0.
|
|
5
|
+
*/
|
|
6
|
+
progress: number;
|
|
7
|
+
/**
|
|
8
|
+
* Width of the progress bar (excluding brackets and percentage).
|
|
9
|
+
* Defaults to 20.
|
|
10
|
+
*/
|
|
11
|
+
width?: number;
|
|
12
|
+
/**
|
|
13
|
+
* Base style for the entire progress bar.
|
|
14
|
+
*/
|
|
15
|
+
style?: Style | Style[];
|
|
16
|
+
/**
|
|
17
|
+
* Style for the brackets (start and end characters).
|
|
18
|
+
* Defaults to `style` or gray.
|
|
19
|
+
*/
|
|
20
|
+
bracketStyle?: Style | Style[];
|
|
21
|
+
/**
|
|
22
|
+
* Specific style for the start bracket. Overrides `bracketStyle`.
|
|
23
|
+
*/
|
|
24
|
+
startStyle?: Style | Style[];
|
|
25
|
+
/**
|
|
26
|
+
* Specific style for the end bracket. Overrides `bracketStyle`.
|
|
27
|
+
*/
|
|
28
|
+
endStyle?: Style | Style[];
|
|
29
|
+
/**
|
|
30
|
+
* Style for the bar (filled and empty parts).
|
|
31
|
+
* Defaults to `style`.
|
|
32
|
+
*/
|
|
33
|
+
barStyle?: Style | Style[];
|
|
34
|
+
/**
|
|
35
|
+
* Specific style for the filled part. Overrides `barStyle`.
|
|
36
|
+
*/
|
|
37
|
+
fillStyle?: Style | Style[];
|
|
38
|
+
/**
|
|
39
|
+
* Specific style for the empty part. Overrides `barStyle`.
|
|
40
|
+
*/
|
|
41
|
+
emptyStyle?: Style | Style[];
|
|
42
|
+
/**
|
|
43
|
+
* Style for the percentage text.
|
|
44
|
+
* Defaults to `style`.
|
|
45
|
+
*/
|
|
46
|
+
percentageStyle?: Style | Style[];
|
|
47
|
+
/**
|
|
48
|
+
* Character to use for the start bracket. Defaults to `[`.
|
|
49
|
+
*/
|
|
50
|
+
startChar?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Character to use for the end bracket. Defaults to `]`.
|
|
53
|
+
*/
|
|
54
|
+
endChar?: string;
|
|
55
|
+
/**
|
|
56
|
+
* Character to use for the filled part. Defaults to `█`.
|
|
57
|
+
*/
|
|
58
|
+
fillChar?: string;
|
|
59
|
+
/**
|
|
60
|
+
* Character to use for the empty part. Defaults to `░`.
|
|
61
|
+
*/
|
|
62
|
+
emptyChar?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Whether to show the percentage text. Defaults to `true`.
|
|
65
|
+
*/
|
|
66
|
+
showPercentage?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Custom formatter for the percentage text.
|
|
69
|
+
*/
|
|
70
|
+
formatPercentage?: (progress: number) => string;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Creates a StyledLine representing a progress bar.
|
|
74
|
+
*/
|
|
75
|
+
export declare function createProgressBar(options: ProgressBarOptions): StyledLine;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a StyledLine representing a progress bar.
|
|
3
|
+
*/
|
|
4
|
+
export function createProgressBar(options) {
|
|
5
|
+
const { progress, width = 20, style, bracketStyle, startStyle, endStyle, barStyle, fillStyle, emptyStyle, percentageStyle, startChar = "[", endChar = "]", fillChar = "█", emptyChar = "░", showPercentage = true, formatPercentage } = options;
|
|
6
|
+
// Clamp progress
|
|
7
|
+
const p = Math.max(0, Math.min(1, progress));
|
|
8
|
+
// Calculate filled width
|
|
9
|
+
const filledWidth = Math.round(p * width);
|
|
10
|
+
const emptyWidth = width - filledWidth;
|
|
11
|
+
// Resolve styles
|
|
12
|
+
const baseStyle = style ?? [];
|
|
13
|
+
const resolvedBracketStyle = bracketStyle ?? baseStyle;
|
|
14
|
+
const resolvedStartStyle = startStyle ?? resolvedBracketStyle;
|
|
15
|
+
const resolvedEndStyle = endStyle ?? resolvedBracketStyle;
|
|
16
|
+
const resolvedBarStyle = barStyle ?? baseStyle;
|
|
17
|
+
const resolvedFillStyle = fillStyle ?? resolvedBarStyle;
|
|
18
|
+
const resolvedEmptyStyle = emptyStyle ?? resolvedBarStyle;
|
|
19
|
+
const resolvedPercentageStyle = percentageStyle ?? baseStyle;
|
|
20
|
+
const segments = [];
|
|
21
|
+
// Start Bracket
|
|
22
|
+
if (startChar) {
|
|
23
|
+
segments.push({ text: startChar, style: resolvedStartStyle });
|
|
24
|
+
}
|
|
25
|
+
// Filled Part
|
|
26
|
+
if (filledWidth > 0) {
|
|
27
|
+
segments.push({ text: fillChar.repeat(filledWidth), style: resolvedFillStyle });
|
|
28
|
+
}
|
|
29
|
+
// Empty Part
|
|
30
|
+
if (emptyWidth > 0) {
|
|
31
|
+
segments.push({ text: emptyChar.repeat(emptyWidth), style: resolvedEmptyStyle });
|
|
32
|
+
}
|
|
33
|
+
// End Bracket
|
|
34
|
+
if (endChar) {
|
|
35
|
+
segments.push({ text: endChar, style: resolvedEndStyle });
|
|
36
|
+
}
|
|
37
|
+
// Percentage
|
|
38
|
+
if (showPercentage) {
|
|
39
|
+
const percentageText = formatPercentage ? formatPercentage(p) : ` ${Math.round(p * 100)}%`;
|
|
40
|
+
segments.push({ text: percentageText, style: resolvedPercentageStyle });
|
|
41
|
+
}
|
|
42
|
+
return { segments };
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { expect, test, describe } from "bun:test";
|
|
2
|
+
import { createProgressBar } from "./progress";
|
|
3
|
+
function getText(line) {
|
|
4
|
+
return line.segments.map((s) => s.text).join("");
|
|
5
|
+
}
|
|
6
|
+
describe("createProgressBar", () => {
|
|
7
|
+
test("generates a default progress bar at 0%", () => {
|
|
8
|
+
const line = createProgressBar({ progress: 0 });
|
|
9
|
+
const text = getText(line);
|
|
10
|
+
expect(text).toContain("[");
|
|
11
|
+
expect(text).toContain("]");
|
|
12
|
+
expect(text).toContain("0%");
|
|
13
|
+
expect(text).toContain("░");
|
|
14
|
+
});
|
|
15
|
+
test("generates a default progress bar at 50%", () => {
|
|
16
|
+
const line = createProgressBar({ progress: 0.5 });
|
|
17
|
+
const text = getText(line);
|
|
18
|
+
expect(text).toContain("50%");
|
|
19
|
+
expect(text).toContain("█");
|
|
20
|
+
expect(text).toContain("░");
|
|
21
|
+
});
|
|
22
|
+
test("generates a default progress bar at 100%", () => {
|
|
23
|
+
const line = createProgressBar({ progress: 1.0 });
|
|
24
|
+
const text = getText(line);
|
|
25
|
+
expect(text).toContain("100%");
|
|
26
|
+
expect(text).not.toContain("░");
|
|
27
|
+
});
|
|
28
|
+
test("allows custom width", () => {
|
|
29
|
+
const width = 10;
|
|
30
|
+
const line = createProgressBar({ progress: 0.5, width });
|
|
31
|
+
const segments = line.segments;
|
|
32
|
+
const filled = segments.find((s) => s.text.includes("█"));
|
|
33
|
+
const empty = segments.find((s) => s.text.includes("░"));
|
|
34
|
+
expect(filled?.text.length).toBe(5);
|
|
35
|
+
expect(empty?.text.length).toBe(5);
|
|
36
|
+
});
|
|
37
|
+
test("applies styles correctly", () => {
|
|
38
|
+
const line = createProgressBar({
|
|
39
|
+
progress: 0.5,
|
|
40
|
+
style: "blue",
|
|
41
|
+
bracketStyle: "red",
|
|
42
|
+
barStyle: "green",
|
|
43
|
+
percentageStyle: "yellow"
|
|
44
|
+
});
|
|
45
|
+
const start = line.segments.find((s) => s.text === "[");
|
|
46
|
+
const filled = line.segments.find((s) => s.text.includes("█"));
|
|
47
|
+
const end = line.segments.find((s) => s.text === "]");
|
|
48
|
+
const percentage = line.segments.find((s) => s.text.includes("%"));
|
|
49
|
+
expect(start?.style).toBe("red");
|
|
50
|
+
expect(filled?.style).toBe("green");
|
|
51
|
+
expect(end?.style).toBe("red");
|
|
52
|
+
expect(percentage?.style).toBe("yellow");
|
|
53
|
+
});
|
|
54
|
+
test("cascades styles (general style -> specific)", () => {
|
|
55
|
+
const line = createProgressBar({
|
|
56
|
+
progress: 0.5,
|
|
57
|
+
style: "blue"
|
|
58
|
+
});
|
|
59
|
+
line.segments.forEach((s) => {
|
|
60
|
+
if (s.text.trim().length > 0) {
|
|
61
|
+
expect(s.style).toBe("blue");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
test("allows custom characters", () => {
|
|
66
|
+
const line = createProgressBar({
|
|
67
|
+
progress: 0.5,
|
|
68
|
+
startChar: "<",
|
|
69
|
+
endChar: ">",
|
|
70
|
+
fillChar: "=",
|
|
71
|
+
emptyChar: "-"
|
|
72
|
+
});
|
|
73
|
+
const text = getText(line);
|
|
74
|
+
expect(text).toContain("<");
|
|
75
|
+
expect(text).toContain(">");
|
|
76
|
+
expect(text).toContain("=");
|
|
77
|
+
expect(text).toContain("-");
|
|
78
|
+
});
|
|
79
|
+
test("hides percentage", () => {
|
|
80
|
+
const line = createProgressBar({
|
|
81
|
+
progress: 0.5,
|
|
82
|
+
showPercentage: false
|
|
83
|
+
});
|
|
84
|
+
const text = getText(line);
|
|
85
|
+
expect(text).not.toContain("%");
|
|
86
|
+
});
|
|
87
|
+
test("formats percentage custom", () => {
|
|
88
|
+
const line = createProgressBar({
|
|
89
|
+
progress: 0.5,
|
|
90
|
+
formatPercentage: (p) => `${p * 10}/10`
|
|
91
|
+
});
|
|
92
|
+
const text = getText(line);
|
|
93
|
+
expect(text).toContain("5/10");
|
|
94
|
+
});
|
|
95
|
+
test("clamping progress", () => {
|
|
96
|
+
const lineLow = createProgressBar({ progress: -0.1 });
|
|
97
|
+
expect(getText(lineLow)).toContain("0%");
|
|
98
|
+
const lineHigh = createProgressBar({ progress: 1.1 });
|
|
99
|
+
expect(getText(lineHigh)).toContain("100%");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type SpinnerFrames = string[];
|
|
2
|
+
export interface SpinnerOptions {
|
|
3
|
+
/**
|
|
4
|
+
* The array of frames to cycle through.
|
|
5
|
+
*/
|
|
6
|
+
frames: SpinnerFrames;
|
|
7
|
+
/**
|
|
8
|
+
* The interval in milliseconds between frames.
|
|
9
|
+
* Defaults to 80ms.
|
|
10
|
+
*/
|
|
11
|
+
interval?: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Common spinner frame presets.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SPINNERS: {
|
|
17
|
+
dots: string[];
|
|
18
|
+
lines: string[];
|
|
19
|
+
arrows: string[];
|
|
20
|
+
circle: string[];
|
|
21
|
+
square: string[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* A stateful spinner that calculates the current frame based on elapsed time.
|
|
25
|
+
* Designed to be used within a render loop (e.g. Printer.print loop).
|
|
26
|
+
*/
|
|
27
|
+
export declare class Spinner {
|
|
28
|
+
private frames;
|
|
29
|
+
private interval;
|
|
30
|
+
private startTime;
|
|
31
|
+
constructor(options: SpinnerOptions);
|
|
32
|
+
/**
|
|
33
|
+
* Returns the current frame based on the elapsed time.
|
|
34
|
+
*/
|
|
35
|
+
getFrame(): string;
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Common spinner frame presets.
|
|
3
|
+
*/
|
|
4
|
+
export const SPINNERS = {
|
|
5
|
+
dots: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
|
|
6
|
+
lines: ["-", "\\", "|", "/"],
|
|
7
|
+
arrows: ["←", "↖", "↑", "↗", "→", "↘", "↓", "↙"],
|
|
8
|
+
circle: ["◐", "◓", "◑", "◒"],
|
|
9
|
+
square: ["▖", "▘", "▝", "▗"]
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A stateful spinner that calculates the current frame based on elapsed time.
|
|
13
|
+
* Designed to be used within a render loop (e.g. Printer.print loop).
|
|
14
|
+
*/
|
|
15
|
+
export class Spinner {
|
|
16
|
+
frames;
|
|
17
|
+
interval;
|
|
18
|
+
startTime;
|
|
19
|
+
constructor(options) {
|
|
20
|
+
this.frames = options.frames;
|
|
21
|
+
this.interval = options.interval ?? 80;
|
|
22
|
+
this.startTime = Date.now();
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Returns the current frame based on the elapsed time.
|
|
26
|
+
*/
|
|
27
|
+
getFrame() {
|
|
28
|
+
const now = Date.now();
|
|
29
|
+
const elapsed = now - this.startTime;
|
|
30
|
+
const index = Math.floor(elapsed / this.interval) % this.frames.length;
|
|
31
|
+
return this.frames[index];
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { expect, test, describe, spyOn, afterEach, beforeEach } from "bun:test";
|
|
2
|
+
import { Spinner, SPINNERS } from "./spinner";
|
|
3
|
+
describe("Spinner Class", () => {
|
|
4
|
+
let now = 1000;
|
|
5
|
+
// Mock Date.now() to control time
|
|
6
|
+
const dateSpy = spyOn(Date, "now").mockImplementation(() => now);
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
now = 1000;
|
|
9
|
+
dateSpy.mockClear();
|
|
10
|
+
dateSpy.mockImplementation(() => now);
|
|
11
|
+
});
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
dateSpy.mockClear();
|
|
14
|
+
});
|
|
15
|
+
test("initializes with correct defaults", () => {
|
|
16
|
+
const spinner = new Spinner({ frames: ["a", "b"] });
|
|
17
|
+
expect(spinner.getFrame()).toBe("a");
|
|
18
|
+
});
|
|
19
|
+
test("advances frames over time", () => {
|
|
20
|
+
const spinner = new Spinner({ frames: ["a", "b", "c"], interval: 100 });
|
|
21
|
+
// t=0 (1000)
|
|
22
|
+
expect(spinner.getFrame()).toBe("a");
|
|
23
|
+
// t=50 (1050) -> still frame 0
|
|
24
|
+
now = 1050;
|
|
25
|
+
expect(spinner.getFrame()).toBe("a");
|
|
26
|
+
// t=100 (1100) -> frame 1
|
|
27
|
+
now = 1100;
|
|
28
|
+
expect(spinner.getFrame()).toBe("b");
|
|
29
|
+
// t=200 (1200) -> frame 2
|
|
30
|
+
now = 1200;
|
|
31
|
+
expect(spinner.getFrame()).toBe("c");
|
|
32
|
+
// t=300 (1300) -> frame 0 (loop)
|
|
33
|
+
now = 1300;
|
|
34
|
+
expect(spinner.getFrame()).toBe("a");
|
|
35
|
+
});
|
|
36
|
+
test("uses custom interval", () => {
|
|
37
|
+
const spinner = new Spinner({ frames: ["a", "b"], interval: 50 });
|
|
38
|
+
// t=0
|
|
39
|
+
expect(spinner.getFrame()).toBe("a");
|
|
40
|
+
// t=50 -> frame 1
|
|
41
|
+
now = 1050;
|
|
42
|
+
expect(spinner.getFrame()).toBe("b");
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
describe("Spinner Presets", () => {
|
|
46
|
+
test("dots preset exists and has frames", () => {
|
|
47
|
+
expect(SPINNERS.dots).toBeDefined();
|
|
48
|
+
expect(SPINNERS.dots.length).toBeGreaterThan(0);
|
|
49
|
+
});
|
|
50
|
+
test("lines preset exists", () => {
|
|
51
|
+
expect(SPINNERS.lines).toBeDefined();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Style, StyledLine } from "./types";
|
|
2
|
+
import { Printer } from "./printer";
|
|
3
|
+
/**
|
|
4
|
+
* Merges multiple columns of StyledLines into a single layout.
|
|
5
|
+
* Ensures proper alignment by padding shorter lines.
|
|
6
|
+
*
|
|
7
|
+
* @param columns - Array of columns, where each column is an array of StyledLines.
|
|
8
|
+
* @param separator - String used to separate columns.
|
|
9
|
+
* @param defaultStyle - Style to apply to the separator and padding.
|
|
10
|
+
* @param widths - Optional fixed widths for each column.
|
|
11
|
+
* @returns A single array of StyledLines representing the merged output.
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergeMultipleColumns(columns: StyledLine[][], separator: string, defaultStyle: Style | Style[], widths?: number[]): StyledLine[];
|
|
14
|
+
/**
|
|
15
|
+
* Prints multiple columns of styled content to the console.
|
|
16
|
+
* A convenience wrapper around `mergeMultipleColumns` and `Printer.print`.
|
|
17
|
+
*
|
|
18
|
+
* @param columns - Array of columns to print.
|
|
19
|
+
* @param options - Layout options (widths, separator, custom printer).
|
|
20
|
+
*/
|
|
21
|
+
export declare function printColumns(columns: StyledLine[][], options?: {
|
|
22
|
+
widths?: number[];
|
|
23
|
+
separator?: string;
|
|
24
|
+
printer?: Printer;
|
|
25
|
+
}): void;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { computeMaxWidth, padLine } from "./utils";
|
|
2
|
+
import { Printer } from "./printer";
|
|
3
|
+
import { RESET } from "./style";
|
|
4
|
+
// -----------------
|
|
5
|
+
// Core Layout & Printing
|
|
6
|
+
// -----------------
|
|
7
|
+
const defaultPrinter = new Printer();
|
|
8
|
+
/**
|
|
9
|
+
* Merges multiple columns of StyledLines into a single layout.
|
|
10
|
+
* Ensures proper alignment by padding shorter lines.
|
|
11
|
+
*
|
|
12
|
+
* @param columns - Array of columns, where each column is an array of StyledLines.
|
|
13
|
+
* @param separator - String used to separate columns.
|
|
14
|
+
* @param defaultStyle - Style to apply to the separator and padding.
|
|
15
|
+
* @param widths - Optional fixed widths for each column.
|
|
16
|
+
* @returns A single array of StyledLines representing the merged output.
|
|
17
|
+
*/
|
|
18
|
+
export function mergeMultipleColumns(columns, separator, defaultStyle, widths) {
|
|
19
|
+
if (columns.length === 0)
|
|
20
|
+
return [];
|
|
21
|
+
const maxLines = Math.max(...columns.map((c) => c.length));
|
|
22
|
+
const colWidths = columns.map((col, i) => {
|
|
23
|
+
if (widths?.[i] !== undefined)
|
|
24
|
+
return widths[i];
|
|
25
|
+
return computeMaxWidth(col);
|
|
26
|
+
});
|
|
27
|
+
const output = [];
|
|
28
|
+
for (let i = 0; i < maxLines; i++) {
|
|
29
|
+
let segments = [];
|
|
30
|
+
for (let j = 0; j < columns.length; j++) {
|
|
31
|
+
const line = columns[j][i] || { segments: [] };
|
|
32
|
+
// Pad if not the last column
|
|
33
|
+
if (j < columns.length - 1) {
|
|
34
|
+
const padded = padLine(line, colWidths[j], defaultStyle);
|
|
35
|
+
segments = [...segments, ...padded.segments, { text: separator, style: defaultStyle }];
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
segments = [...segments, ...line.segments];
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
output.push({ segments });
|
|
42
|
+
}
|
|
43
|
+
return output;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Prints multiple columns of styled content to the console.
|
|
47
|
+
* A convenience wrapper around `mergeMultipleColumns` and `Printer.print`.
|
|
48
|
+
*
|
|
49
|
+
* @param columns - Array of columns to print.
|
|
50
|
+
* @param options - Layout options (widths, separator, custom printer).
|
|
51
|
+
*/
|
|
52
|
+
export function printColumns(columns, options = {}) {
|
|
53
|
+
const { widths, separator = " ", printer = defaultPrinter } = options;
|
|
54
|
+
const defaultStyle = RESET;
|
|
55
|
+
const mergedLines = mergeMultipleColumns(columns, separator, defaultStyle, widths);
|
|
56
|
+
printer.print(mergedLines);
|
|
57
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { expect, test, describe, spyOn, afterEach } from "bun:test";
|
|
2
|
+
import { mergeMultipleColumns, printColumns } from "./layout";
|
|
3
|
+
import { getLineLength } from "./utils";
|
|
4
|
+
const lineA = { segments: [{ text: "Hello", style: [] }] };
|
|
5
|
+
const lineB = { segments: [{ text: "World!!", style: [] }] };
|
|
6
|
+
describe("Layout Utilities", () => {
|
|
7
|
+
const stdoutSpy = spyOn(process.stdout, "write").mockImplementation(() => true);
|
|
8
|
+
afterEach(() => {
|
|
9
|
+
stdoutSpy.mockClear();
|
|
10
|
+
});
|
|
11
|
+
test("mergeMultipleColumns handles asymmetric column lengths", () => {
|
|
12
|
+
const merged = mergeMultipleColumns([[lineA], [lineB, lineB]], " | ", "", [10]);
|
|
13
|
+
expect(merged.length).toBe(2);
|
|
14
|
+
expect(getLineLength(merged[1])).toBe(10 + 3 + 7);
|
|
15
|
+
});
|
|
16
|
+
test("printColumns executes correctly", () => {
|
|
17
|
+
printColumns([[lineA], [lineB]]);
|
|
18
|
+
expect(stdoutSpy).toHaveBeenCalled();
|
|
19
|
+
const output = stdoutSpy.mock.calls[0][0];
|
|
20
|
+
expect(output).toContain("Hello");
|
|
21
|
+
expect(output).toContain("World!!");
|
|
22
|
+
});
|
|
23
|
+
test("printColumns handles undefined style in segments", () => {
|
|
24
|
+
const lineNoStyle = { segments: [{ text: "NoStyle" }] };
|
|
25
|
+
printColumns([[lineNoStyle]]);
|
|
26
|
+
expect(stdoutSpy).toHaveBeenCalled();
|
|
27
|
+
const output = stdoutSpy.mock.calls[0][0];
|
|
28
|
+
expect(output).toContain("NoStyle");
|
|
29
|
+
});
|
|
30
|
+
test("printColumns handles empty columns", () => {
|
|
31
|
+
printColumns([]);
|
|
32
|
+
expect(stdoutSpy).toHaveBeenCalled(); // Should clear lines if interactive, or do nothing.
|
|
33
|
+
});
|
|
34
|
+
test("printColumns handles 3 columns", () => {
|
|
35
|
+
printColumns([[lineA], [lineA], [lineB]]);
|
|
36
|
+
expect(stdoutSpy).toHaveBeenCalled();
|
|
37
|
+
const output = stdoutSpy.mock.calls[0][0];
|
|
38
|
+
expect(output).toContain("Hello");
|
|
39
|
+
expect(output).toContain("World!!");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { PrinterOptions, StyledLine } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Handles rendering StyledLines to the terminal with support for interactive overwriting.
|
|
4
|
+
*/
|
|
5
|
+
export declare class Printer {
|
|
6
|
+
private linesRendered;
|
|
7
|
+
private isInteractive;
|
|
8
|
+
constructor(options?: PrinterOptions);
|
|
9
|
+
/**
|
|
10
|
+
* Generates the clear sequence to move cursor and clear previously rendered lines.
|
|
11
|
+
*/
|
|
12
|
+
private getClearSequence;
|
|
13
|
+
/**
|
|
14
|
+
* Renders an array of StyledLines to the standard output.
|
|
15
|
+
* If interactive mode is enabled, it clears the previously printed lines first.
|
|
16
|
+
*
|
|
17
|
+
* @param lines - The lines to print.
|
|
18
|
+
*/
|
|
19
|
+
print(lines: StyledLine[]): void;
|
|
20
|
+
}
|