@gajae-code/tui 0.13.2 → 0.13.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.
- package/CHANGELOG.md +14 -6
- package/dist/types/components/gajae-pet.d.ts +39 -21
- package/dist/types/components/ouroboros-pet.d.ts +23 -0
- package/dist/types/components/select-list.d.ts +3 -2
- package/package.json +3 -3
- package/src/components/gajae-pet.ts +284 -43
- package/src/components/ouroboros-pet-frames.json +488 -0
- package/src/components/ouroboros-pet.ts +150 -0
- package/src/components/select-list.ts +17 -11
- package/src/tui.ts +37 -29
|
@@ -1,11 +1,25 @@
|
|
|
1
|
+
import * as zlib from "node:zlib";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
OUROBOROS_HEART_STEPS,
|
|
5
|
+
OUROBOROS_IDLE_STEPS,
|
|
6
|
+
OUROBOROS_PIXEL_GRIDS,
|
|
7
|
+
OUROBOROS_WORK_CRY_STEPS,
|
|
8
|
+
OUROBOROS_WORK_ENTER_STEPS,
|
|
9
|
+
OUROBOROS_WORK_EXIT_STEPS,
|
|
10
|
+
OUROBOROS_WORK_HEART_STEPS,
|
|
11
|
+
OUROBOROS_WORK_STEPS,
|
|
12
|
+
type OuroborosFrameName,
|
|
13
|
+
} from "./ouroboros-pet";
|
|
14
|
+
|
|
1
15
|
/**
|
|
2
16
|
* ┌─ GAJAE PET SPRITE SPEC ────────────────────────────────────────────────┐
|
|
3
|
-
*
|
|
4
|
-
* data: no PNGs
|
|
5
|
-
*
|
|
17
|
+
* Pets are square pixel sprites drawn beside the composer. Everything here is
|
|
18
|
+
* data: no PNGs or binary assets. Current pet frames are 16×16 and render into
|
|
19
|
+
* the same terminal footprint.
|
|
6
20
|
*
|
|
7
21
|
* GRID RULES
|
|
8
|
-
* -
|
|
22
|
+
* - Every frame within a skin has the same square dimensions.
|
|
9
23
|
* - `.` = transparent. Keep the outer columns transparent so the sprite sits
|
|
10
24
|
* snug beside the input box (the widget reserves +1 column of slack).
|
|
11
25
|
*
|
|
@@ -26,7 +40,8 @@
|
|
|
26
40
|
* RENDERING: buildGajaePixelFrames({ protocol, cellWidthPx, cellHeightPx,
|
|
27
41
|
* targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
|
|
28
42
|
* once. Kitty uses a native `Y=` sub-cell drop (set by the widget) to sit on the
|
|
29
|
-
* composer border; sixel uses transparent top padding
|
|
43
|
+
* composer border; sixel uses transparent top padding; iTerm2 uses an inline PNG
|
|
44
|
+
* sized to the reserved cell block.
|
|
30
45
|
*
|
|
31
46
|
* BEHAVIOR (timing, positioning, on/off) lives in
|
|
32
47
|
* packages/coding-agent/src/modes/components/gajae-pet-widget.ts.
|
|
@@ -34,17 +49,15 @@
|
|
|
34
49
|
* ADD A FRAME: draw the grid → add its name to GajaePixelFrameName → register it in
|
|
35
50
|
* PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
|
|
36
51
|
*
|
|
37
|
-
* ADD A PET (skin): append one
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* widget reads `burst` to animate — no other file needs editing. Recolor with a palette
|
|
41
|
-
* spread (see BLUE_PALETTE); add frames only for poses the catalog lacks.
|
|
52
|
+
* ADD A PET (skin): append one PET_SKINS entry with its palette, frame registry,
|
|
53
|
+
* base/idle/work animations and burst. The id flows into PetSkinId/PetMode
|
|
54
|
+
* automatically; settings, `/pet`, and both selectors derive from PET_SKINS.
|
|
42
55
|
* └────────────────────────────────────────────────────────────────────────┘
|
|
43
56
|
*/
|
|
44
57
|
type Rgb = readonly [number, number, number];
|
|
45
58
|
|
|
46
59
|
export type Palette = Record<string, Rgb | null>;
|
|
47
|
-
export const PET_SKIN_IDS = ["red", "blue"] as const;
|
|
60
|
+
export const PET_SKIN_IDS = ["red", "blue", "ouroboros"] as const;
|
|
48
61
|
export type PetSkinId = (typeof PET_SKIN_IDS)[number];
|
|
49
62
|
/** Every pet mode: "off" plus each skin id, in menu order. */
|
|
50
63
|
export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
|
|
@@ -54,6 +67,11 @@ export function isPetMode(value: string): value is PetMode {
|
|
|
54
67
|
return (PET_MODE_IDS as readonly string[]).includes(value);
|
|
55
68
|
}
|
|
56
69
|
|
|
70
|
+
/** Resolve a persisted mode after a skin has been removed. Explicit "off" remains off. */
|
|
71
|
+
export function resolvePetMode(value: string): PetMode {
|
|
72
|
+
return isPetMode(value) ? value : "red";
|
|
73
|
+
}
|
|
74
|
+
|
|
57
75
|
const RED_PALETTE: Palette = {
|
|
58
76
|
".": null, // transparent
|
|
59
77
|
K: [74, 20, 8], // outline dark
|
|
@@ -79,6 +97,15 @@ const BLUE_PALETTE: Palette = {
|
|
|
79
97
|
A: [37, 120, 200], // muted blue (antenna)
|
|
80
98
|
w: [230, 247, 255], // foam (tear)
|
|
81
99
|
};
|
|
100
|
+
const OUROBOROS_PALETTE: Palette = {
|
|
101
|
+
".": null,
|
|
102
|
+
D: [20, 100, 48], // closed / crying eye
|
|
103
|
+
R: [174, 232, 14], // vivid lime body
|
|
104
|
+
r: [112, 146, 190], // cool blue underside (#7092BE)
|
|
105
|
+
G: [255, 231, 134], // pale-yellow eye
|
|
106
|
+
A: [255, 137, 180], // pink tongue and heart accent
|
|
107
|
+
w: [190, 231, 255], // tear
|
|
108
|
+
};
|
|
82
109
|
|
|
83
110
|
// ------------------------------------------------------------------------
|
|
84
111
|
// Real-pixel frames (codex-pets style): the same grids encoded as terminal
|
|
@@ -188,6 +215,7 @@ export type GajaePixelFrameName =
|
|
|
188
215
|
| "cry1"
|
|
189
216
|
| "cry2"
|
|
190
217
|
| "cry3";
|
|
218
|
+
export type PetFrameName = GajaePixelFrameName | OuroborosFrameName;
|
|
191
219
|
|
|
192
220
|
const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
|
|
193
221
|
base: F0,
|
|
@@ -210,6 +238,14 @@ export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, numbe
|
|
|
210
238
|
["flex", 480],
|
|
211
239
|
["base", 260],
|
|
212
240
|
];
|
|
241
|
+
export const GAJAE_IDLE_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]> = [
|
|
242
|
+
["base", 1100],
|
|
243
|
+
["gazeL", 350],
|
|
244
|
+
["base", 500],
|
|
245
|
+
["gazeR", 350],
|
|
246
|
+
["base", 800],
|
|
247
|
+
["flicker", 150],
|
|
248
|
+
];
|
|
213
249
|
|
|
214
250
|
/**
|
|
215
251
|
* A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
|
|
@@ -218,9 +254,9 @@ export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, numbe
|
|
|
218
254
|
*/
|
|
219
255
|
export interface PetBurst {
|
|
220
256
|
/** Frames played once, in order, at the start of the burst. */
|
|
221
|
-
intro: ReadonlyArray<readonly [
|
|
257
|
+
intro: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
222
258
|
/** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */
|
|
223
|
-
tail?: { frames: readonly
|
|
259
|
+
tail?: { frames: readonly PetFrameName[]; stepMs: number; ms: number };
|
|
224
260
|
}
|
|
225
261
|
|
|
226
262
|
/** Everything that defines a pet skin: identity, UI copy, colors and behavior. */
|
|
@@ -231,8 +267,16 @@ export interface PetSkin {
|
|
|
231
267
|
/** One-line selector/settings description. */
|
|
232
268
|
description: string;
|
|
233
269
|
palette: Palette;
|
|
270
|
+
frames: Readonly<Record<string, string[]>>;
|
|
271
|
+
baseFrame: PetFrameName;
|
|
272
|
+
idle: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
273
|
+
workEnter?: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
274
|
+
work: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
275
|
+
workExit?: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
234
276
|
/** Idle burst animation played between quiet idle loops. */
|
|
235
277
|
burst: PetBurst;
|
|
278
|
+
/** Optional variants that interrupt and then resume the work loop. */
|
|
279
|
+
workBursts?: readonly PetBurst[];
|
|
236
280
|
}
|
|
237
281
|
|
|
238
282
|
/** Skin registry — the single source for palettes, behavior and selector/command copy. */
|
|
@@ -242,6 +286,10 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
|
|
|
242
286
|
label: "RedGajae",
|
|
243
287
|
description: "The Red Crab, who likes to work-out.",
|
|
244
288
|
palette: RED_PALETTE,
|
|
289
|
+
frames: PIXEL_GRIDS,
|
|
290
|
+
baseFrame: "base",
|
|
291
|
+
idle: GAJAE_IDLE_STEPS,
|
|
292
|
+
work: PARA_PARA_STEPS,
|
|
245
293
|
burst: {
|
|
246
294
|
intro: PARA_PARA_STEPS,
|
|
247
295
|
tail: { frames: ["flex", "base"], stepMs: 200, ms: 1000 },
|
|
@@ -252,11 +300,31 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
|
|
|
252
300
|
label: "BlueGajae",
|
|
253
301
|
description: "The Blue Crab, who wants to rest.",
|
|
254
302
|
palette: BLUE_PALETTE,
|
|
303
|
+
frames: PIXEL_GRIDS,
|
|
304
|
+
baseFrame: "base",
|
|
305
|
+
idle: GAJAE_IDLE_STEPS,
|
|
306
|
+
work: PARA_PARA_STEPS,
|
|
255
307
|
burst: {
|
|
256
308
|
intro: PARA_PARA_STEPS,
|
|
257
309
|
tail: { frames: ["cry1", "cry2", "cry3"], stepMs: 110, ms: 990 },
|
|
258
310
|
},
|
|
259
311
|
},
|
|
312
|
+
ouroboros: {
|
|
313
|
+
id: "ouroboros",
|
|
314
|
+
label: "Ouroboros",
|
|
315
|
+
description: "The little snake who keeps going.",
|
|
316
|
+
palette: OUROBOROS_PALETTE,
|
|
317
|
+
frames: OUROBOROS_PIXEL_GRIDS,
|
|
318
|
+
baseFrame: "idle",
|
|
319
|
+
idle: OUROBOROS_IDLE_STEPS,
|
|
320
|
+
workEnter: OUROBOROS_WORK_ENTER_STEPS,
|
|
321
|
+
work: OUROBOROS_WORK_STEPS,
|
|
322
|
+
workExit: OUROBOROS_WORK_EXIT_STEPS,
|
|
323
|
+
burst: {
|
|
324
|
+
intro: OUROBOROS_HEART_STEPS,
|
|
325
|
+
},
|
|
326
|
+
workBursts: [{ intro: OUROBOROS_WORK_HEART_STEPS }, { intro: OUROBOROS_WORK_CRY_STEPS }],
|
|
327
|
+
},
|
|
260
328
|
};
|
|
261
329
|
|
|
262
330
|
/** Total burst duration (intro beats plus the looping tail). */
|
|
@@ -266,7 +334,7 @@ export function petBurstDurationMs(burst: PetBurst): number {
|
|
|
266
334
|
}
|
|
267
335
|
|
|
268
336
|
/** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */
|
|
269
|
-
export function petBurstFrame(burst: PetBurst, elapsed: number, now: number):
|
|
337
|
+
export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName {
|
|
270
338
|
let t = elapsed;
|
|
271
339
|
for (const [frame, ms] of burst.intro) {
|
|
272
340
|
if (t < ms) return frame;
|
|
@@ -279,8 +347,10 @@ export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): Ga
|
|
|
279
347
|
|
|
280
348
|
/** Test-only access to logical art; production rendering still uses encoded frames. */
|
|
281
349
|
export const __gajaePetTestHooks = {
|
|
282
|
-
getPixelGrid(name:
|
|
283
|
-
|
|
350
|
+
getPixelGrid(name: PetFrameName, skin: PetSkinId = "red"): string[] {
|
|
351
|
+
const grid = PET_SKINS[skin].frames[name];
|
|
352
|
+
if (!grid) throw new Error(`Unknown ${skin} pet frame: ${name}`);
|
|
353
|
+
return [...grid];
|
|
284
354
|
},
|
|
285
355
|
};
|
|
286
356
|
|
|
@@ -349,6 +419,128 @@ export function encodeGridSixel(
|
|
|
349
419
|
return `${out}\x1b\\`;
|
|
350
420
|
}
|
|
351
421
|
|
|
422
|
+
const MAX_PET_PNG_DIMENSION = 16_384;
|
|
423
|
+
const MAX_PET_PNG_RAW_BYTES = 64 * 1024 * 1024;
|
|
424
|
+
const MAX_PET_FRAME_DIMENSION = 4_096;
|
|
425
|
+
const MAX_PET_FRAME_RGBA_BYTES = 16 * 1024 * 1024;
|
|
426
|
+
|
|
427
|
+
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
428
|
+
const typeBytes = Buffer.from(type, "ascii");
|
|
429
|
+
const payload = Buffer.concat([typeBytes, Buffer.from(data)]);
|
|
430
|
+
let crc = 0xffffffff;
|
|
431
|
+
for (const byte of payload) {
|
|
432
|
+
crc ^= byte;
|
|
433
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
434
|
+
}
|
|
435
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
436
|
+
const out = Buffer.allocUnsafe(12 + data.length);
|
|
437
|
+
out.writeUInt32BE(data.length, 0);
|
|
438
|
+
Buffer.from(payload).copy(out, 4);
|
|
439
|
+
out.writeUInt32BE(crc, 8 + data.length);
|
|
440
|
+
return out;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function validatePngGrid(
|
|
444
|
+
grid: string[],
|
|
445
|
+
scale: number,
|
|
446
|
+
topPaddingPx: number,
|
|
447
|
+
bottomPaddingPx: number,
|
|
448
|
+
): {
|
|
449
|
+
gridWidth: number;
|
|
450
|
+
gridHeight: number;
|
|
451
|
+
width: number;
|
|
452
|
+
spriteHeight: number;
|
|
453
|
+
height: number;
|
|
454
|
+
} {
|
|
455
|
+
const gridHeight = grid.length;
|
|
456
|
+
const gridWidth = grid[0]?.length ?? 0;
|
|
457
|
+
if (gridHeight === 0 || gridWidth === 0 || grid.some(row => row.length !== gridWidth)) {
|
|
458
|
+
throw new Error("iTerm2 pet grid must be non-empty and rectangular");
|
|
459
|
+
}
|
|
460
|
+
if (!Number.isFinite(scale) || scale <= 0) throw new Error("iTerm2 pet scale must be finite and positive");
|
|
461
|
+
for (const [name, value] of [
|
|
462
|
+
["top padding", topPaddingPx],
|
|
463
|
+
["bottom padding", bottomPaddingPx],
|
|
464
|
+
] as const) {
|
|
465
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
466
|
+
throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
|
|
467
|
+
}
|
|
468
|
+
const width = Math.round(gridWidth * scale);
|
|
469
|
+
const spriteHeight = Math.round(gridHeight * scale);
|
|
470
|
+
const height = spriteHeight + topPaddingPx + bottomPaddingPx;
|
|
471
|
+
if (
|
|
472
|
+
!Number.isSafeInteger(width) ||
|
|
473
|
+
!Number.isSafeInteger(spriteHeight) ||
|
|
474
|
+
!Number.isSafeInteger(height) ||
|
|
475
|
+
width <= 0 ||
|
|
476
|
+
spriteHeight <= 0 ||
|
|
477
|
+
height <= 0 ||
|
|
478
|
+
width > MAX_PET_PNG_DIMENSION ||
|
|
479
|
+
height > MAX_PET_PNG_DIMENSION
|
|
480
|
+
) {
|
|
481
|
+
throw new Error("iTerm2 pet PNG dimensions are out of bounds");
|
|
482
|
+
}
|
|
483
|
+
const stride = width * 4 + 1;
|
|
484
|
+
const rawBytes = stride * height;
|
|
485
|
+
if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
|
|
486
|
+
throw new Error("iTerm2 pet PNG allocation is out of bounds");
|
|
487
|
+
}
|
|
488
|
+
return { gridWidth, gridHeight, width, spriteHeight, height };
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** Encode a grid as an iTerm2 inline PNG. */
|
|
492
|
+
export function encodeGridIterm2(
|
|
493
|
+
grid: string[],
|
|
494
|
+
scale: number,
|
|
495
|
+
topPaddingPx = 0,
|
|
496
|
+
bottomPaddingPx = 0,
|
|
497
|
+
palette: Palette = RED_PALETTE,
|
|
498
|
+
): string {
|
|
499
|
+
const { gridWidth, gridHeight, width, spriteHeight, height } = validatePngGrid(
|
|
500
|
+
grid,
|
|
501
|
+
scale,
|
|
502
|
+
topPaddingPx,
|
|
503
|
+
bottomPaddingPx,
|
|
504
|
+
);
|
|
505
|
+
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
506
|
+
for (let y = 0; y < height; y++) {
|
|
507
|
+
for (let x = 0; x < width; x++) {
|
|
508
|
+
const sourceY = y - topPaddingPx;
|
|
509
|
+
const rgb =
|
|
510
|
+
sourceY < 0 || sourceY >= spriteHeight
|
|
511
|
+
? null
|
|
512
|
+
: palette[
|
|
513
|
+
grid[Math.min(gridHeight - 1, Math.floor(sourceY / scale))][
|
|
514
|
+
Math.min(gridWidth - 1, Math.floor(x / scale))
|
|
515
|
+
]
|
|
516
|
+
];
|
|
517
|
+
const offset = y * (width * 4 + 1) + 1 + x * 4;
|
|
518
|
+
if (!rgb) continue;
|
|
519
|
+
raw[offset] = rgb[0];
|
|
520
|
+
raw[offset + 1] = rgb[1];
|
|
521
|
+
raw[offset + 2] = rgb[2];
|
|
522
|
+
raw[offset + 3] = 255;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
const compressed = zlib.deflateSync(raw);
|
|
526
|
+
const header = Buffer.alloc(13);
|
|
527
|
+
header.writeUInt32BE(width, 0);
|
|
528
|
+
header.writeUInt32BE(height, 4);
|
|
529
|
+
header[8] = 8;
|
|
530
|
+
header[9] = 6;
|
|
531
|
+
const png = Buffer.concat([
|
|
532
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
533
|
+
pngChunk("IHDR", header),
|
|
534
|
+
pngChunk("IDAT", compressed),
|
|
535
|
+
pngChunk("IEND", new Uint8Array()),
|
|
536
|
+
]);
|
|
537
|
+
// Use explicit pixel dimensions. Cell dimensions are only a layout hint for
|
|
538
|
+
// the surrounding TUI; asking iTerm2 to fit a small PNG into a cell box makes
|
|
539
|
+
// it stretch or shrink differently from Kitty/Ghostty.
|
|
540
|
+
const params = `width=${width}px;height=${height}px;preserveAspectRatio=0;inline=1`;
|
|
541
|
+
return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
|
|
542
|
+
}
|
|
543
|
+
|
|
352
544
|
/** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
|
|
353
545
|
export function encodeGridKitty(
|
|
354
546
|
grid: string[],
|
|
@@ -407,9 +599,9 @@ export function encodeGridKitty(
|
|
|
407
599
|
|
|
408
600
|
export interface GajaePixelFrames {
|
|
409
601
|
/** escape payload per logical frame (drawn at the current cursor cell) */
|
|
410
|
-
frames: Record<
|
|
602
|
+
frames: Record<string, string>;
|
|
411
603
|
/** protocol the frames were encoded for */
|
|
412
|
-
protocol: "sixel" | "kitty";
|
|
604
|
+
protocol: "sixel" | "kitty" | "iterm2";
|
|
413
605
|
widthPx: number;
|
|
414
606
|
heightPx: number;
|
|
415
607
|
columns: number;
|
|
@@ -420,11 +612,11 @@ export interface GajaePixelFrames {
|
|
|
420
612
|
|
|
421
613
|
/**
|
|
422
614
|
* Build overlay pixel frames exactly `targetRows` terminal rows tall when the
|
|
423
|
-
* terminal cells permit it.
|
|
424
|
-
*
|
|
615
|
+
* terminal cells permit it. Each skin owns its source resolution so future
|
|
616
|
+
* additions can opt into denser art without changing the terminal footprint.
|
|
425
617
|
*/
|
|
426
618
|
export function buildGajaePixelFrames(options: {
|
|
427
|
-
protocol: "sixel" | "kitty";
|
|
619
|
+
protocol: "sixel" | "kitty" | "iterm2";
|
|
428
620
|
cellWidthPx: number;
|
|
429
621
|
cellHeightPx: number;
|
|
430
622
|
targetRows?: number;
|
|
@@ -433,46 +625,95 @@ export function buildGajaePixelFrames(options: {
|
|
|
433
625
|
/** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
|
|
434
626
|
kittyCellYOffsetPx?: number;
|
|
435
627
|
kittyImageId?: number;
|
|
628
|
+
/** Transparent iTerm2-only top padding for half-cell vertical alignment. */
|
|
629
|
+
iterm2TopPaddingPx?: number;
|
|
630
|
+
/** Transparent iTerm2-only bottom padding inside the two-row canvas. */
|
|
631
|
+
iterm2BottomPaddingPx?: number;
|
|
436
632
|
/** Color skin for the sprite palette (default "red"). */
|
|
437
633
|
skin?: PetSkinId;
|
|
438
634
|
}): GajaePixelFrames {
|
|
439
635
|
const targetRows = options.targetRows ?? 2;
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
636
|
+
if (!Number.isFinite(options.cellWidthPx) || options.cellWidthPx <= 0) {
|
|
637
|
+
throw new Error("Pet cell width must be finite and positive");
|
|
638
|
+
}
|
|
639
|
+
if (!Number.isFinite(options.cellHeightPx) || options.cellHeightPx <= 0) {
|
|
640
|
+
throw new Error("Pet cell height must be finite and positive");
|
|
641
|
+
}
|
|
642
|
+
if (!Number.isFinite(targetRows) || targetRows <= 0) throw new Error("Pet target rows must be finite and positive");
|
|
643
|
+
const skin = PET_SKINS[options.skin ?? "red"];
|
|
644
|
+
const grids = Object.entries(skin.frames);
|
|
645
|
+
const firstGrid = grids[0]?.[1];
|
|
646
|
+
if (!firstGrid?.[0]) throw new Error(`Pet skin ${skin.id} has no pixel frames`);
|
|
647
|
+
const gridHeight = firstGrid.length;
|
|
648
|
+
const gridWidth = firstGrid[0].length;
|
|
649
|
+
for (const [name, grid] of grids) {
|
|
650
|
+
if (grid.length !== gridHeight || grid.some(row => row.length !== gridWidth)) {
|
|
651
|
+
throw new Error(`Pet frame ${skin.id}/${name} does not match ${gridWidth}x${gridHeight}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const scale = Math.max(1, (targetRows * options.cellHeightPx) / gridHeight);
|
|
655
|
+
const widthPx = Math.round(gridWidth * scale);
|
|
656
|
+
const visibleHeightPx = Math.round(gridHeight * scale);
|
|
444
657
|
const columns = Math.ceil(widthPx / options.cellWidthPx);
|
|
445
658
|
const rows = Math.ceil(visibleHeightPx / options.cellHeightPx);
|
|
446
659
|
const allocatedHeightPx = rows * options.cellHeightPx;
|
|
447
660
|
const topPaddingPx =
|
|
448
661
|
allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
|
|
449
662
|
const heightPx = visibleHeightPx + topPaddingPx;
|
|
450
|
-
const rasterRows = Math.ceil(heightPx / options.cellHeightPx);
|
|
451
663
|
// Center the square sprite in its (cols * cellWidth) block, which the ceil()
|
|
452
664
|
// column rounding can make wider than the sprite itself.
|
|
453
665
|
const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
|
|
454
666
|
const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
|
|
455
667
|
const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
|
|
668
|
+
const canvasWidthPx = widthPx + leftPaddingPx + rightPaddingPx;
|
|
669
|
+
if (
|
|
670
|
+
widthPx > MAX_PET_FRAME_DIMENSION ||
|
|
671
|
+
heightPx > MAX_PET_FRAME_DIMENSION ||
|
|
672
|
+
canvasWidthPx > MAX_PET_FRAME_DIMENSION ||
|
|
673
|
+
!Number.isSafeInteger(canvasWidthPx * heightPx * 4) ||
|
|
674
|
+
canvasWidthPx * heightPx * 4 > MAX_PET_FRAME_RGBA_BYTES
|
|
675
|
+
) {
|
|
676
|
+
throw new Error("Pet frame dimensions are out of bounds");
|
|
677
|
+
}
|
|
456
678
|
const imageId = options.kittyImageId ?? 0xc0de;
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
for (const name of Object.keys(PIXEL_GRIDS) as GajaePixelFrameName[]) {
|
|
679
|
+
const frames: Record<string, string> = {};
|
|
680
|
+
for (const [name, grid] of grids) {
|
|
460
681
|
frames[name] =
|
|
461
682
|
options.protocol === "sixel"
|
|
462
|
-
? encodeGridSixel(
|
|
463
|
-
:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
683
|
+
? encodeGridSixel(grid, scale, topPaddingPx, skin.palette)
|
|
684
|
+
: options.protocol === "iterm2"
|
|
685
|
+
? encodeGridIterm2(
|
|
686
|
+
grid,
|
|
687
|
+
scale,
|
|
688
|
+
options.iterm2TopPaddingPx ?? 0,
|
|
689
|
+
options.iterm2BottomPaddingPx ?? 0,
|
|
690
|
+
skin.palette,
|
|
691
|
+
)
|
|
692
|
+
: encodeGridKitty(
|
|
693
|
+
grid,
|
|
694
|
+
scale,
|
|
695
|
+
imageId,
|
|
696
|
+
columns,
|
|
697
|
+
rows,
|
|
698
|
+
topPaddingPx,
|
|
699
|
+
options.kittyCellYOffsetPx ?? 0,
|
|
700
|
+
leftPaddingPx,
|
|
701
|
+
rightPaddingPx,
|
|
702
|
+
skin.palette,
|
|
703
|
+
);
|
|
475
704
|
}
|
|
476
705
|
|
|
477
|
-
|
|
706
|
+
const protocolHeightPx =
|
|
707
|
+
options.protocol === "iterm2"
|
|
708
|
+
? visibleHeightPx + (options.iterm2TopPaddingPx ?? 0) + (options.iterm2BottomPaddingPx ?? 0)
|
|
709
|
+
: heightPx;
|
|
710
|
+
return {
|
|
711
|
+
frames,
|
|
712
|
+
protocol: options.protocol,
|
|
713
|
+
widthPx,
|
|
714
|
+
heightPx: protocolHeightPx,
|
|
715
|
+
columns,
|
|
716
|
+
rows,
|
|
717
|
+
rasterRows: Math.ceil(protocolHeightPx / options.cellHeightPx),
|
|
718
|
+
};
|
|
478
719
|
}
|