@gonrocca/zero-pi 0.1.12 → 0.1.13

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.
@@ -1,237 +1,237 @@
1
- // zero-pi - animated startup banner.
2
- //
3
- // A pi extension that renders the ZERO wordmark as ASCII-safe Tetris cells.
4
- // The animated mode progressively assembles the wordmark from the bottom up,
5
- // then settles into the complete banner. The extension stays dependency-free
6
- // and keeps the historical ZERO_BANNER controls.
7
-
8
- type RGB = [number, number, number];
9
-
10
- const COLORS: Record<string, RGB> = {
11
- cyan: [80, 210, 255],
12
- blue: [116, 151, 255],
13
- mint: [79, 221, 171],
14
- amber: [238, 190, 92],
15
- rose: [255, 106, 122],
16
- violet: [175, 138, 255],
17
- peak: [255, 245, 218],
18
- };
19
-
20
- /** Number of rows in every glyph. */
21
- const ROWS = 6;
22
-
23
- // Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
24
- const FONT: Record<string, string[]> = {
25
- Z: ["ZZZZZ", " ZZ", " ZZ ", " ZZ ", "ZZ ", "ZZZZZ"],
26
- E: ["EEEEE", "EE ", "EEEE ", "EE ", "EE ", "EEEEE"],
27
- R: ["RRRR ", "RR RR", "RRRR ", "RR RR", "RR R", "RR R"],
28
- O: [" OOO ", "OO OO", "OO OO", "OO OO", "OO OO", " OOO "],
29
- P: ["PPPP ", "PP PP", "PP PP", "PPPP ", "PP ", "PP "],
30
- I: ["IIIII", " II ", " II ", " II ", " II ", "IIIII"],
31
- "-": [" ", " ", "-----", " ", " ", " "],
32
- " ": [" ", " ", " ", " ", " ", " "],
33
- };
34
-
35
- const PIECE_COLORS: Record<string, RGB> = {
36
- Z: COLORS.cyan,
37
- E: COLORS.amber,
38
- R: COLORS.mint,
39
- O: COLORS.rose,
40
- P: COLORS.violet,
41
- I: COLORS.blue,
42
- "-": COLORS.amber,
43
- };
44
-
45
- interface Matrix {
46
- rows: string[];
47
- width: number;
48
- totalCells: number;
49
- ranks: Map<string, number>;
50
- }
51
-
52
- function glyphFor(ch: string): string[] {
53
- return FONT[ch.toUpperCase()] ?? FONT[" "];
54
- }
55
-
56
- function matrixForText(text: string): Matrix {
57
- const rows = Array.from({ length: ROWS }, () => "");
58
- for (const [index, raw] of Array.from(text).entries()) {
59
- const glyph = glyphFor(raw);
60
- for (let r = 0; r < ROWS; r++) {
61
- rows[r] += (index === 0 ? "" : " ") + glyph[r];
62
- }
63
- }
64
-
65
- const cells: Array<{ row: number; col: number }> = [];
66
- for (let row = 0; row < ROWS; row++) {
67
- for (let col = 0; col < rows[row].length; col++) {
68
- if (rows[row][col] !== " ") cells.push({ row, col });
69
- }
70
- }
71
-
72
- // Tetris reads as a pile building upward: bottom rows settle first.
73
- cells.sort((a, b) => b.row - a.row || a.col - b.col);
74
-
75
- const ranks = new Map<string, number>();
76
- for (const [rank, cell] of cells.entries()) {
77
- ranks.set(`${cell.row}:${cell.col}`, rank);
78
- }
79
-
80
- return {
81
- rows,
82
- width: rows[0]?.length ?? 0,
83
- totalCells: cells.length,
84
- ranks,
85
- };
86
- }
87
-
88
- /** Lay a word out as six rows of ASCII-safe Tetris cells. */
89
- export function bannerLines(text = "ZERO"): string[] {
90
- return matrixForText(text).rows.map((row) =>
91
- Array.from(row)
92
- .map((cell) => (cell === " " ? " " : "[]"))
93
- .join(""),
94
- );
95
- }
96
-
97
- function lerp(a: number, b: number, t: number): number {
98
- return a + (b - a) * t;
99
- }
100
-
101
- function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
102
- const k = Math.max(0, Math.min(1, t));
103
- return [
104
- Math.round(lerp(c1[0], c2[0], k)),
105
- Math.round(lerp(c1[1], c2[1], k)),
106
- Math.round(lerp(c1[2], c2[2], k)),
107
- ];
108
- }
109
-
110
- function ansiFg([r, g, b]: RGB, text: string): string {
111
- return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
112
- }
113
-
114
- function colorForPiece(piece: string, row: number, rank: number, active: boolean): RGB {
115
- if (active) return COLORS.peak;
116
- const base = PIECE_COLORS[piece] ?? COLORS.blue;
117
- const heightWarmth = (ROWS - 1 - row) / Math.max(1, ROWS - 1);
118
- const rankPulse = (rank % 7) / 18;
119
- return lerpColor(base, COLORS.peak, heightWarmth * 0.18 + rankPulse);
120
- }
121
-
122
- function paintMatrixLine(matrix: Matrix, row: number, visibleCells: number): string {
123
- let out = "";
124
- const line = matrix.rows[row] ?? "";
125
- for (let col = 0; col < matrix.width; col++) {
126
- const piece = line[col] ?? " ";
127
- if (piece === " ") {
128
- out += " ";
129
- continue;
130
- }
131
- const rank = matrix.ranks.get(`${row}:${col}`) ?? Number.POSITIVE_INFINITY;
132
- if (rank >= visibleCells) {
133
- out += " ";
134
- continue;
135
- }
136
- out += ansiFg(colorForPiece(piece, row, rank, rank === visibleCells - 1), "[]");
137
- }
138
- return out;
139
- }
140
-
141
- interface OutStream {
142
- write(chunk: string): unknown;
143
- isTTY?: boolean;
144
- }
145
-
146
- /** Whether colour output is appropriate for the given stream. */
147
- function shouldColor(stream: OutStream): boolean {
148
- if (process.env.NO_COLOR) return false;
149
- if (process.env.FORCE_COLOR) return true;
150
- return Boolean(stream && stream.isTTY);
151
- }
152
-
153
- /** The three banner modes, resolved from the ZERO_BANNER environment variable. */
154
- export type BannerMode = "off" | "static" | "shimmer";
155
-
156
- /** Resolve the banner mode from an environment-variable value. */
157
- export function resolveBannerMode(raw: string | undefined): BannerMode {
158
- const value = (raw ?? "").trim().toLowerCase();
159
- if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
160
- if (value === "static" || value === "plain" || value === "no-animate") return "static";
161
- return "shimmer";
162
- }
163
-
164
- // Atomics.wait gives us a synchronous sleep without adding a dependency. The
165
- // banner renders before Pi's TUI takes over, so blocking briefly is intentional.
166
- const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
167
- function sleepSync(ms: number): void {
168
- if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
169
- }
170
-
171
- function easeOutCubic(t: number): number {
172
- return 1 - Math.pow(1 - t, 3);
173
- }
174
-
175
- /** Options for {@link renderBanner}. */
176
- export interface RenderOptions {
177
- mode?: BannerMode;
178
- text?: string;
179
- frames?: number;
180
- frameMs?: number;
181
- stream?: OutStream;
182
- }
183
-
184
- /**
185
- * Render the ZERO banner synchronously.
186
- *
187
- * On a non-colour stream the banner is printed plain. Otherwise the animated
188
- * mode assembles cells frame by frame, then settles on the completed wordmark.
189
- */
190
- export function renderBanner(options: RenderOptions = {}): void {
191
- const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
192
- if (mode === "off") return;
193
-
194
- const stream: OutStream = options.stream ?? process.stdout;
195
- const matrix = matrixForText(options.text ?? "ZERO");
196
-
197
- if (!shouldColor(stream)) {
198
- stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
199
- return;
200
- }
201
-
202
- stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
203
-
204
- if (mode === "shimmer") {
205
- const frames = Math.max(2, options.frames ?? 42);
206
- const frameMs = Math.max(1, options.frameMs ?? 18);
207
- for (let f = 0; f < frames; f++) {
208
- const t = easeOutCubic(f / (frames - 1));
209
- const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
210
- stream.write(`\x1b[${ROWS}A\r`);
211
- for (let r = 0; r < ROWS; r++) {
212
- stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
213
- }
214
- sleepSync(frameMs);
215
- }
216
- }
217
-
218
- stream.write(`\x1b[${ROWS}A\r`);
219
- for (let r = 0; r < ROWS; r++) {
220
- stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells) + "\n");
221
- }
222
- stream.write("\n");
223
- }
224
-
225
- /**
226
- * The pi extension entry point.
227
- *
228
- * The pi argument is accepted for API compatibility. The banner is rendered
229
- * defensively: visual sugar should never break an interactive session.
230
- */
231
- export default function register(_pi?: unknown): void {
232
- try {
233
- renderBanner();
234
- } catch {
235
- // A banner failure must never break a pi session.
236
- }
237
- }
1
+ // zero-pi - animated startup banner.
2
+ //
3
+ // A pi extension that renders the ZERO wordmark as ASCII-safe Tetris cells.
4
+ // The animated mode progressively assembles the wordmark from the bottom up,
5
+ // then settles into the complete banner. The extension stays dependency-free
6
+ // and keeps the historical ZERO_BANNER controls.
7
+
8
+ type RGB = [number, number, number];
9
+
10
+ const COLORS: Record<string, RGB> = {
11
+ cyan: [80, 210, 255],
12
+ blue: [116, 151, 255],
13
+ mint: [79, 221, 171],
14
+ amber: [238, 190, 92],
15
+ rose: [255, 106, 122],
16
+ violet: [175, 138, 255],
17
+ peak: [255, 245, 218],
18
+ };
19
+
20
+ /** Number of rows in every glyph. */
21
+ const ROWS = 6;
22
+
23
+ // Five-cell-wide glyphs. Non-space characters become visible Tetris cells.
24
+ const FONT: Record<string, string[]> = {
25
+ Z: ["ZZZZZ", " ZZ", " ZZ ", " ZZ ", "ZZ ", "ZZZZZ"],
26
+ E: ["EEEEE", "EE ", "EEEE ", "EE ", "EE ", "EEEEE"],
27
+ R: ["RRRR ", "RR RR", "RRRR ", "RR RR", "RR R", "RR R"],
28
+ O: [" OOO ", "OO OO", "OO OO", "OO OO", "OO OO", " OOO "],
29
+ P: ["PPPP ", "PP PP", "PP PP", "PPPP ", "PP ", "PP "],
30
+ I: ["IIIII", " II ", " II ", " II ", " II ", "IIIII"],
31
+ "-": [" ", " ", "-----", " ", " ", " "],
32
+ " ": [" ", " ", " ", " ", " ", " "],
33
+ };
34
+
35
+ const PIECE_COLORS: Record<string, RGB> = {
36
+ Z: COLORS.cyan,
37
+ E: COLORS.amber,
38
+ R: COLORS.mint,
39
+ O: COLORS.rose,
40
+ P: COLORS.violet,
41
+ I: COLORS.blue,
42
+ "-": COLORS.amber,
43
+ };
44
+
45
+ interface Matrix {
46
+ rows: string[];
47
+ width: number;
48
+ totalCells: number;
49
+ ranks: Map<string, number>;
50
+ }
51
+
52
+ function glyphFor(ch: string): string[] {
53
+ return FONT[ch.toUpperCase()] ?? FONT[" "];
54
+ }
55
+
56
+ function matrixForText(text: string): Matrix {
57
+ const rows = Array.from({ length: ROWS }, () => "");
58
+ for (const [index, raw] of Array.from(text).entries()) {
59
+ const glyph = glyphFor(raw);
60
+ for (let r = 0; r < ROWS; r++) {
61
+ rows[r] += (index === 0 ? "" : " ") + glyph[r];
62
+ }
63
+ }
64
+
65
+ const cells: Array<{ row: number; col: number }> = [];
66
+ for (let row = 0; row < ROWS; row++) {
67
+ for (let col = 0; col < rows[row].length; col++) {
68
+ if (rows[row][col] !== " ") cells.push({ row, col });
69
+ }
70
+ }
71
+
72
+ // Tetris reads as a pile building upward: bottom rows settle first.
73
+ cells.sort((a, b) => b.row - a.row || a.col - b.col);
74
+
75
+ const ranks = new Map<string, number>();
76
+ for (const [rank, cell] of cells.entries()) {
77
+ ranks.set(`${cell.row}:${cell.col}`, rank);
78
+ }
79
+
80
+ return {
81
+ rows,
82
+ width: rows[0]?.length ?? 0,
83
+ totalCells: cells.length,
84
+ ranks,
85
+ };
86
+ }
87
+
88
+ /** Lay a word out as six rows of ASCII-safe Tetris cells. */
89
+ export function bannerLines(text = "ZERO"): string[] {
90
+ return matrixForText(text).rows.map((row) =>
91
+ Array.from(row)
92
+ .map((cell) => (cell === " " ? " " : "[]"))
93
+ .join(""),
94
+ );
95
+ }
96
+
97
+ function lerp(a: number, b: number, t: number): number {
98
+ return a + (b - a) * t;
99
+ }
100
+
101
+ function lerpColor(c1: RGB, c2: RGB, t: number): RGB {
102
+ const k = Math.max(0, Math.min(1, t));
103
+ return [
104
+ Math.round(lerp(c1[0], c2[0], k)),
105
+ Math.round(lerp(c1[1], c2[1], k)),
106
+ Math.round(lerp(c1[2], c2[2], k)),
107
+ ];
108
+ }
109
+
110
+ function ansiFg([r, g, b]: RGB, text: string): string {
111
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`;
112
+ }
113
+
114
+ function colorForPiece(piece: string, row: number, rank: number, active: boolean): RGB {
115
+ if (active) return COLORS.peak;
116
+ const base = PIECE_COLORS[piece] ?? COLORS.blue;
117
+ const heightWarmth = (ROWS - 1 - row) / Math.max(1, ROWS - 1);
118
+ const rankPulse = (rank % 7) / 18;
119
+ return lerpColor(base, COLORS.peak, heightWarmth * 0.18 + rankPulse);
120
+ }
121
+
122
+ function paintMatrixLine(matrix: Matrix, row: number, visibleCells: number): string {
123
+ let out = "";
124
+ const line = matrix.rows[row] ?? "";
125
+ for (let col = 0; col < matrix.width; col++) {
126
+ const piece = line[col] ?? " ";
127
+ if (piece === " ") {
128
+ out += " ";
129
+ continue;
130
+ }
131
+ const rank = matrix.ranks.get(`${row}:${col}`) ?? Number.POSITIVE_INFINITY;
132
+ if (rank >= visibleCells) {
133
+ out += " ";
134
+ continue;
135
+ }
136
+ out += ansiFg(colorForPiece(piece, row, rank, rank === visibleCells - 1), "[]");
137
+ }
138
+ return out;
139
+ }
140
+
141
+ interface OutStream {
142
+ write(chunk: string): unknown;
143
+ isTTY?: boolean;
144
+ }
145
+
146
+ /** Whether colour output is appropriate for the given stream. */
147
+ function shouldColor(stream: OutStream): boolean {
148
+ if (process.env.NO_COLOR) return false;
149
+ if (process.env.FORCE_COLOR) return true;
150
+ return Boolean(stream && stream.isTTY);
151
+ }
152
+
153
+ /** The three banner modes, resolved from the ZERO_BANNER environment variable. */
154
+ export type BannerMode = "off" | "static" | "shimmer";
155
+
156
+ /** Resolve the banner mode from an environment-variable value. */
157
+ export function resolveBannerMode(raw: string | undefined): BannerMode {
158
+ const value = (raw ?? "").trim().toLowerCase();
159
+ if (value === "off" || value === "0" || value === "false" || value === "none") return "off";
160
+ if (value === "static" || value === "plain" || value === "no-animate") return "static";
161
+ return "shimmer";
162
+ }
163
+
164
+ // Atomics.wait gives us a synchronous sleep without adding a dependency. The
165
+ // banner renders before Pi's TUI takes over, so blocking briefly is intentional.
166
+ const SLEEP_SLOT = new Int32Array(new SharedArrayBuffer(4));
167
+ function sleepSync(ms: number): void {
168
+ if (ms > 0) Atomics.wait(SLEEP_SLOT, 0, 0, ms);
169
+ }
170
+
171
+ function easeOutCubic(t: number): number {
172
+ return 1 - Math.pow(1 - t, 3);
173
+ }
174
+
175
+ /** Options for {@link renderBanner}. */
176
+ export interface RenderOptions {
177
+ mode?: BannerMode;
178
+ text?: string;
179
+ frames?: number;
180
+ frameMs?: number;
181
+ stream?: OutStream;
182
+ }
183
+
184
+ /**
185
+ * Render the ZERO banner synchronously.
186
+ *
187
+ * On a non-colour stream the banner is printed plain. Otherwise the animated
188
+ * mode assembles cells frame by frame, then settles on the completed wordmark.
189
+ */
190
+ export function renderBanner(options: RenderOptions = {}): void {
191
+ const mode = options.mode ?? resolveBannerMode(process.env.ZERO_BANNER);
192
+ if (mode === "off") return;
193
+
194
+ const stream: OutStream = options.stream ?? process.stdout;
195
+ const matrix = matrixForText(options.text ?? "ZERO");
196
+
197
+ if (!shouldColor(stream)) {
198
+ stream.write("\n" + bannerLines(options.text ?? "ZERO").join("\n") + "\n\n");
199
+ return;
200
+ }
201
+
202
+ stream.write("\n" + Array.from({ length: ROWS }, () => "").join("\n") + "\n");
203
+
204
+ if (mode === "shimmer") {
205
+ const frames = Math.max(2, options.frames ?? 42);
206
+ const frameMs = Math.max(1, options.frameMs ?? 18);
207
+ for (let f = 0; f < frames; f++) {
208
+ const t = easeOutCubic(f / (frames - 1));
209
+ const visibleCells = Math.max(1, Math.ceil(matrix.totalCells * t));
210
+ stream.write(`\x1b[${ROWS}A\r`);
211
+ for (let r = 0; r < ROWS; r++) {
212
+ stream.write("\x1b[2K" + paintMatrixLine(matrix, r, visibleCells) + "\n");
213
+ }
214
+ sleepSync(frameMs);
215
+ }
216
+ }
217
+
218
+ stream.write(`\x1b[${ROWS}A\r`);
219
+ for (let r = 0; r < ROWS; r++) {
220
+ stream.write("\x1b[2K" + paintMatrixLine(matrix, r, matrix.totalCells) + "\n");
221
+ }
222
+ stream.write("\n");
223
+ }
224
+
225
+ /**
226
+ * The pi extension entry point.
227
+ *
228
+ * The pi argument is accepted for API compatibility. The banner is rendered
229
+ * defensively: visual sugar should never break an interactive session.
230
+ */
231
+ export default function register(_pi?: unknown): void {
232
+ try {
233
+ renderBanner();
234
+ } catch {
235
+ // A banner failure must never break a pi session.
236
+ }
237
+ }