@gajae-code/tui 0.13.2 → 0.14.0
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 +25 -1
- package/dist/types/components/gajae-pet.d.ts +121 -22
- package/dist/types/components/ouroboros-pet.d.ts +23 -0
- package/dist/types/components/select-list.d.ts +3 -2
- package/dist/types/terminal-capabilities.d.ts +22 -0
- package/dist/types/terminal.d.ts +9 -0
- package/dist/types/tui.d.ts +122 -4
- package/package.json +3 -3
- package/src/components/gajae-pet.ts +645 -49
- 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/terminal-capabilities.ts +137 -0
- package/src/terminal.ts +33 -0
- package/src/tui.ts +1026 -135
|
@@ -1,11 +1,26 @@
|
|
|
1
|
+
import * as zlib from "node:zlib";
|
|
2
|
+
|
|
3
|
+
import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities";
|
|
4
|
+
import {
|
|
5
|
+
OUROBOROS_HEART_STEPS,
|
|
6
|
+
OUROBOROS_IDLE_STEPS,
|
|
7
|
+
OUROBOROS_PIXEL_GRIDS,
|
|
8
|
+
OUROBOROS_WORK_CRY_STEPS,
|
|
9
|
+
OUROBOROS_WORK_ENTER_STEPS,
|
|
10
|
+
OUROBOROS_WORK_EXIT_STEPS,
|
|
11
|
+
OUROBOROS_WORK_HEART_STEPS,
|
|
12
|
+
OUROBOROS_WORK_STEPS,
|
|
13
|
+
type OuroborosFrameName,
|
|
14
|
+
} from "./ouroboros-pet";
|
|
15
|
+
|
|
1
16
|
/**
|
|
2
17
|
* ┌─ GAJAE PET SPRITE SPEC ────────────────────────────────────────────────┐
|
|
3
|
-
*
|
|
4
|
-
* data: no PNGs
|
|
5
|
-
*
|
|
18
|
+
* Pets are square pixel sprites drawn beside the composer. Everything here is
|
|
19
|
+
* data: no PNGs or binary assets. Current pet frames are 16×16 and render into
|
|
20
|
+
* the same terminal footprint.
|
|
6
21
|
*
|
|
7
22
|
* GRID RULES
|
|
8
|
-
* -
|
|
23
|
+
* - Every frame within a skin has the same square dimensions.
|
|
9
24
|
* - `.` = transparent. Keep the outer columns transparent so the sprite sits
|
|
10
25
|
* snug beside the input box (the widget reserves +1 column of slack).
|
|
11
26
|
*
|
|
@@ -26,7 +41,8 @@
|
|
|
26
41
|
* RENDERING: buildGajaePixelFrames({ protocol, cellWidthPx, cellHeightPx,
|
|
27
42
|
* targetRows: 2 }) scales the art to 2 terminal rows and encodes each frame
|
|
28
43
|
* 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
|
|
44
|
+
* composer border; sixel uses transparent top padding; iTerm2 uses an inline PNG
|
|
45
|
+
* sized to the reserved cell block.
|
|
30
46
|
*
|
|
31
47
|
* BEHAVIOR (timing, positioning, on/off) lives in
|
|
32
48
|
* packages/coding-agent/src/modes/components/gajae-pet-widget.ts.
|
|
@@ -34,17 +50,15 @@
|
|
|
34
50
|
* ADD A FRAME: draw the grid → add its name to GajaePixelFrameName → register it in
|
|
35
51
|
* PIXEL_GRIDS → reference it from an idle/work loop or a skin burst.
|
|
36
52
|
*
|
|
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.
|
|
53
|
+
* ADD A PET (skin): append one PET_SKINS entry with its palette, frame registry,
|
|
54
|
+
* base/idle/work animations and burst. The id flows into PetSkinId/PetMode
|
|
55
|
+
* automatically; settings, `/pet`, and both selectors derive from PET_SKINS.
|
|
42
56
|
* └────────────────────────────────────────────────────────────────────────┘
|
|
43
57
|
*/
|
|
44
58
|
type Rgb = readonly [number, number, number];
|
|
45
59
|
|
|
46
60
|
export type Palette = Record<string, Rgb | null>;
|
|
47
|
-
export const PET_SKIN_IDS = ["red", "blue"] as const;
|
|
61
|
+
export const PET_SKIN_IDS = ["red", "blue", "ouroboros"] as const;
|
|
48
62
|
export type PetSkinId = (typeof PET_SKIN_IDS)[number];
|
|
49
63
|
/** Every pet mode: "off" plus each skin id, in menu order. */
|
|
50
64
|
export const PET_MODE_IDS = ["off", ...PET_SKIN_IDS] as const;
|
|
@@ -54,6 +68,11 @@ export function isPetMode(value: string): value is PetMode {
|
|
|
54
68
|
return (PET_MODE_IDS as readonly string[]).includes(value);
|
|
55
69
|
}
|
|
56
70
|
|
|
71
|
+
/** Resolve a persisted mode after a skin has been removed. Explicit "off" remains off. */
|
|
72
|
+
export function resolvePetMode(value: string): PetMode {
|
|
73
|
+
return isPetMode(value) ? value : "red";
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
const RED_PALETTE: Palette = {
|
|
58
77
|
".": null, // transparent
|
|
59
78
|
K: [74, 20, 8], // outline dark
|
|
@@ -79,6 +98,15 @@ const BLUE_PALETTE: Palette = {
|
|
|
79
98
|
A: [37, 120, 200], // muted blue (antenna)
|
|
80
99
|
w: [230, 247, 255], // foam (tear)
|
|
81
100
|
};
|
|
101
|
+
const OUROBOROS_PALETTE: Palette = {
|
|
102
|
+
".": null,
|
|
103
|
+
D: [20, 100, 48], // closed / crying eye
|
|
104
|
+
R: [174, 232, 14], // vivid lime body
|
|
105
|
+
r: [112, 146, 190], // cool blue underside (#7092BE)
|
|
106
|
+
G: [255, 231, 134], // pale-yellow eye
|
|
107
|
+
A: [255, 137, 180], // pink tongue and heart accent
|
|
108
|
+
w: [190, 231, 255], // tear
|
|
109
|
+
};
|
|
82
110
|
|
|
83
111
|
// ------------------------------------------------------------------------
|
|
84
112
|
// Real-pixel frames (codex-pets style): the same grids encoded as terminal
|
|
@@ -188,6 +216,7 @@ export type GajaePixelFrameName =
|
|
|
188
216
|
| "cry1"
|
|
189
217
|
| "cry2"
|
|
190
218
|
| "cry3";
|
|
219
|
+
export type PetFrameName = GajaePixelFrameName | OuroborosFrameName;
|
|
191
220
|
|
|
192
221
|
const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
|
|
193
222
|
base: F0,
|
|
@@ -203,13 +232,22 @@ const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
|
|
|
203
232
|
};
|
|
204
233
|
|
|
205
234
|
/** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */
|
|
206
|
-
export
|
|
235
|
+
export type GajaeGifFrameTuple = readonly [GajaePixelFrameName, number];
|
|
236
|
+
export const PARA_PARA_STEPS: readonly GajaeGifFrameTuple[] = [
|
|
207
237
|
["danceL", 300],
|
|
208
238
|
["danceR", 300],
|
|
209
239
|
["base", 260],
|
|
210
240
|
["flex", 480],
|
|
211
241
|
["base", 260],
|
|
212
242
|
];
|
|
243
|
+
export const GAJAE_IDLE_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, number]> = [
|
|
244
|
+
["base", 1100],
|
|
245
|
+
["gazeL", 350],
|
|
246
|
+
["base", 500],
|
|
247
|
+
["gazeR", 350],
|
|
248
|
+
["base", 800],
|
|
249
|
+
["flicker", 150],
|
|
250
|
+
];
|
|
213
251
|
|
|
214
252
|
/**
|
|
215
253
|
* A skin's idle burst: a short intro sequence, then an optional looping tail. It drives
|
|
@@ -218,9 +256,9 @@ export const PARA_PARA_STEPS: ReadonlyArray<readonly [GajaePixelFrameName, numbe
|
|
|
218
256
|
*/
|
|
219
257
|
export interface PetBurst {
|
|
220
258
|
/** Frames played once, in order, at the start of the burst. */
|
|
221
|
-
intro: ReadonlyArray<readonly [
|
|
259
|
+
intro: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
222
260
|
/** Frames cycled every `stepMs` for `ms` after the intro (a held or looping finish). */
|
|
223
|
-
tail?: { frames: readonly
|
|
261
|
+
tail?: { frames: readonly PetFrameName[]; stepMs: number; ms: number };
|
|
224
262
|
}
|
|
225
263
|
|
|
226
264
|
/** Everything that defines a pet skin: identity, UI copy, colors and behavior. */
|
|
@@ -231,8 +269,16 @@ export interface PetSkin {
|
|
|
231
269
|
/** One-line selector/settings description. */
|
|
232
270
|
description: string;
|
|
233
271
|
palette: Palette;
|
|
272
|
+
frames: Readonly<Record<string, string[]>>;
|
|
273
|
+
baseFrame: PetFrameName;
|
|
274
|
+
idle: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
275
|
+
workEnter?: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
276
|
+
work: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
277
|
+
workExit?: ReadonlyArray<readonly [PetFrameName, number]>;
|
|
234
278
|
/** Idle burst animation played between quiet idle loops. */
|
|
235
279
|
burst: PetBurst;
|
|
280
|
+
/** Optional variants that interrupt and then resume the work loop. */
|
|
281
|
+
workBursts?: readonly PetBurst[];
|
|
236
282
|
}
|
|
237
283
|
|
|
238
284
|
/** Skin registry — the single source for palettes, behavior and selector/command copy. */
|
|
@@ -242,6 +288,10 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
|
|
|
242
288
|
label: "RedGajae",
|
|
243
289
|
description: "The Red Crab, who likes to work-out.",
|
|
244
290
|
palette: RED_PALETTE,
|
|
291
|
+
frames: PIXEL_GRIDS,
|
|
292
|
+
baseFrame: "base",
|
|
293
|
+
idle: GAJAE_IDLE_STEPS,
|
|
294
|
+
work: PARA_PARA_STEPS,
|
|
245
295
|
burst: {
|
|
246
296
|
intro: PARA_PARA_STEPS,
|
|
247
297
|
tail: { frames: ["flex", "base"], stepMs: 200, ms: 1000 },
|
|
@@ -252,38 +302,355 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
|
|
|
252
302
|
label: "BlueGajae",
|
|
253
303
|
description: "The Blue Crab, who wants to rest.",
|
|
254
304
|
palette: BLUE_PALETTE,
|
|
305
|
+
frames: PIXEL_GRIDS,
|
|
306
|
+
baseFrame: "base",
|
|
307
|
+
idle: GAJAE_IDLE_STEPS,
|
|
308
|
+
work: PARA_PARA_STEPS,
|
|
255
309
|
burst: {
|
|
256
310
|
intro: PARA_PARA_STEPS,
|
|
257
311
|
tail: { frames: ["cry1", "cry2", "cry3"], stepMs: 110, ms: 990 },
|
|
258
312
|
},
|
|
259
313
|
},
|
|
314
|
+
ouroboros: {
|
|
315
|
+
id: "ouroboros",
|
|
316
|
+
label: "Ouroboros",
|
|
317
|
+
description: "The little snake who keeps going.",
|
|
318
|
+
palette: OUROBOROS_PALETTE,
|
|
319
|
+
frames: OUROBOROS_PIXEL_GRIDS,
|
|
320
|
+
baseFrame: "idle",
|
|
321
|
+
idle: OUROBOROS_IDLE_STEPS,
|
|
322
|
+
workEnter: OUROBOROS_WORK_ENTER_STEPS,
|
|
323
|
+
work: OUROBOROS_WORK_STEPS,
|
|
324
|
+
workExit: OUROBOROS_WORK_EXIT_STEPS,
|
|
325
|
+
burst: {
|
|
326
|
+
intro: OUROBOROS_HEART_STEPS,
|
|
327
|
+
},
|
|
328
|
+
workBursts: [{ intro: OUROBOROS_WORK_HEART_STEPS }, { intro: OUROBOROS_WORK_CRY_STEPS }],
|
|
329
|
+
},
|
|
260
330
|
};
|
|
261
331
|
|
|
262
332
|
/** Total burst duration (intro beats plus the looping tail). */
|
|
263
333
|
export function petBurstDurationMs(burst: PetBurst): number {
|
|
264
|
-
const introMs = burst.intro.reduce((sum, [,
|
|
334
|
+
const introMs = burst.intro.reduce((sum, [, delayMs]) => sum + delayMs, 0);
|
|
265
335
|
return introMs + (burst.tail?.ms ?? 0);
|
|
266
336
|
}
|
|
267
337
|
|
|
268
338
|
/** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */
|
|
269
|
-
export function petBurstFrame(burst: PetBurst, elapsed: number, now: number):
|
|
339
|
+
export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName {
|
|
270
340
|
let t = elapsed;
|
|
271
|
-
for (const [
|
|
272
|
-
if (t <
|
|
273
|
-
t -=
|
|
341
|
+
for (const [name, delayMs] of burst.intro) {
|
|
342
|
+
if (t < delayMs) return name;
|
|
343
|
+
t -= delayMs;
|
|
274
344
|
}
|
|
275
345
|
const tail = burst.tail;
|
|
276
|
-
if (!tail) return burst.intro[burst.intro.length - 1][0];
|
|
346
|
+
if (!tail || tail.frames.length === 0) return burst.intro[burst.intro.length - 1]?.[0] ?? "base";
|
|
277
347
|
return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length];
|
|
278
348
|
}
|
|
279
349
|
|
|
280
350
|
/** Test-only access to logical art; production rendering still uses encoded frames. */
|
|
281
351
|
export const __gajaePetTestHooks = {
|
|
282
|
-
getPixelGrid(name:
|
|
283
|
-
|
|
352
|
+
getPixelGrid(name: PetFrameName, skin: PetSkinId = "red"): string[] {
|
|
353
|
+
const grid = PET_SKINS[skin].frames[name];
|
|
354
|
+
if (!grid) throw new Error(`Unknown ${skin} pet frame: ${name}`);
|
|
355
|
+
return [...grid];
|
|
284
356
|
},
|
|
285
357
|
};
|
|
286
358
|
|
|
359
|
+
export interface GajaeGifFrame {
|
|
360
|
+
readonly name: PetFrameName;
|
|
361
|
+
readonly delayMs: number;
|
|
362
|
+
}
|
|
363
|
+
export type GajaeGifTimeline = readonly GajaeGifFrame[];
|
|
364
|
+
export interface GajaeGifRectangle {
|
|
365
|
+
readonly width?: number;
|
|
366
|
+
readonly height?: number;
|
|
367
|
+
}
|
|
368
|
+
export interface GajaeGifDisplaySize {
|
|
369
|
+
/** iTerm2 display width: bare numbers are terminal cells; strings may use px or auto. */
|
|
370
|
+
readonly width: number | string;
|
|
371
|
+
/** iTerm2 display height: bare numbers are terminal cells; strings may use px or auto. */
|
|
372
|
+
readonly height: number | string;
|
|
373
|
+
}
|
|
374
|
+
export interface GajaeGifContentInset {
|
|
375
|
+
/** Transparent top padding in source pixels. */
|
|
376
|
+
readonly topPx?: number;
|
|
377
|
+
/** Transparent bottom padding in source pixels. */
|
|
378
|
+
readonly bottomPx?: number;
|
|
379
|
+
}
|
|
380
|
+
export interface GajaePetGifArtifact {
|
|
381
|
+
readonly bytes: Uint8Array;
|
|
382
|
+
readonly base64: string;
|
|
383
|
+
readonly width: number;
|
|
384
|
+
readonly height: number;
|
|
385
|
+
readonly frames: readonly GajaeGifFrame[];
|
|
386
|
+
readonly skin: PetSkinId;
|
|
387
|
+
readonly multipart: readonly string[];
|
|
388
|
+
readonly tmuxDcs: readonly string[];
|
|
389
|
+
}
|
|
390
|
+
export interface GajaePetGifOptions {
|
|
391
|
+
readonly skin?: PetSkinId;
|
|
392
|
+
readonly timeline?: GajaeGifTimeline;
|
|
393
|
+
readonly cellWidthPx?: number;
|
|
394
|
+
readonly cellHeightPx?: number;
|
|
395
|
+
readonly targetRows?: number;
|
|
396
|
+
readonly rectangle?: GajaeGifRectangle;
|
|
397
|
+
readonly displaySize?: GajaeGifDisplaySize;
|
|
398
|
+
readonly contentInset?: GajaeGifContentInset;
|
|
399
|
+
}
|
|
400
|
+
const GIF_CLEAR = 256,
|
|
401
|
+
GIF_END = 257;
|
|
402
|
+
function gifLzw(pixels: number[], minCodeSize = 8): Uint8Array {
|
|
403
|
+
const out: number[] = [],
|
|
404
|
+
codes = pixels.flatMap(p => [GIF_CLEAR, p]).concat(GIF_END);
|
|
405
|
+
let bits = 0,
|
|
406
|
+
value = 0;
|
|
407
|
+
for (const code of codes) {
|
|
408
|
+
value |= code << bits;
|
|
409
|
+
bits += minCodeSize + 1;
|
|
410
|
+
while (bits >= 8) {
|
|
411
|
+
out.push(value & 255);
|
|
412
|
+
value >>>= 8;
|
|
413
|
+
bits -= 8;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (bits) out.push(value & 255);
|
|
417
|
+
const blocks: number[] = [minCodeSize];
|
|
418
|
+
for (let i = 0; i < out.length; i += 255) {
|
|
419
|
+
const part = out.slice(i, i + 255);
|
|
420
|
+
blocks.push(part.length, ...part);
|
|
421
|
+
}
|
|
422
|
+
blocks.push(0);
|
|
423
|
+
return Uint8Array.from(blocks);
|
|
424
|
+
}
|
|
425
|
+
export const idleTimeline = (): GajaeGifTimeline => [
|
|
426
|
+
{ name: "base", delayMs: 700 },
|
|
427
|
+
{ name: "gazeL", delayMs: 180 },
|
|
428
|
+
{ name: "base", delayMs: 700 },
|
|
429
|
+
{ name: "gazeR", delayMs: 180 },
|
|
430
|
+
{ name: "flicker", delayMs: 120 },
|
|
431
|
+
];
|
|
432
|
+
export const workingTimeline = (): GajaeGifTimeline => PARA_PARA_STEPS.map(([name, delayMs]) => ({ name, delayMs }));
|
|
433
|
+
export const burstTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => {
|
|
434
|
+
const burst = PET_SKINS[skin].burst;
|
|
435
|
+
const frames: GajaeGifFrame[] = burst.intro.map(([name, delayMs]) => ({ name, delayMs }));
|
|
436
|
+
const tail = burst.tail;
|
|
437
|
+
if (!tail || tail.frames.length === 0) return frames;
|
|
438
|
+
for (let elapsed = 0; elapsed < tail.ms; elapsed += tail.stepMs) {
|
|
439
|
+
frames.push({
|
|
440
|
+
name: tail.frames[Math.floor(elapsed / tail.stepMs) % tail.frames.length],
|
|
441
|
+
delayMs: Math.min(tail.stepMs, tail.ms - elapsed),
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return frames;
|
|
445
|
+
};
|
|
446
|
+
export const previewTimeline = (skin: PetSkinId = "red"): GajaeGifTimeline => burstTimeline(skin);
|
|
447
|
+
function isGifTimeline(input: GajaePetGifOptions | GajaeGifTimeline): input is GajaeGifTimeline {
|
|
448
|
+
return Array.isArray(input);
|
|
449
|
+
}
|
|
450
|
+
function gifOptions(input: GajaePetGifOptions | GajaeGifTimeline): Required<
|
|
451
|
+
Pick<GajaePetGifOptions, "skin" | "timeline" | "cellWidthPx" | "cellHeightPx" | "targetRows">
|
|
452
|
+
> & {
|
|
453
|
+
rectangle?: GajaeGifRectangle;
|
|
454
|
+
displaySize?: GajaeGifDisplaySize;
|
|
455
|
+
contentInset?: GajaeGifContentInset;
|
|
456
|
+
} {
|
|
457
|
+
if (isGifTimeline(input)) {
|
|
458
|
+
return { skin: "red", timeline: input, cellWidthPx: 1, cellHeightPx: 1, targetRows: 16 };
|
|
459
|
+
}
|
|
460
|
+
return {
|
|
461
|
+
skin: input.skin ?? "red",
|
|
462
|
+
timeline: input.timeline ?? idleTimeline(),
|
|
463
|
+
cellWidthPx: input.cellWidthPx ?? 1,
|
|
464
|
+
cellHeightPx: input.cellHeightPx ?? 1,
|
|
465
|
+
targetRows: input.targetRows ?? 16,
|
|
466
|
+
rectangle: input.rectangle,
|
|
467
|
+
displaySize: input.displaySize,
|
|
468
|
+
contentInset: input.contentInset,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
export function encodeGajaePetGif(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact {
|
|
472
|
+
const o = gifOptions(input),
|
|
473
|
+
rect = o.rectangle ?? {};
|
|
474
|
+
if (o.timeline.length === 0) throw new Error("GIF timeline must not be empty");
|
|
475
|
+
for (const frame of o.timeline) {
|
|
476
|
+
if (!Number.isFinite(frame.delayMs) || frame.delayMs < 0) throw new Error("Invalid GIF frame delay");
|
|
477
|
+
}
|
|
478
|
+
const width = rect.width ?? rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx));
|
|
479
|
+
const height = rect.height ?? Math.round(Math.max(1, o.targetRows * o.cellHeightPx));
|
|
480
|
+
const valid = (n: number, label: string): number => {
|
|
481
|
+
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0 || n > 0xffff) throw new Error(`Invalid GIF ${label}`);
|
|
482
|
+
return n;
|
|
483
|
+
};
|
|
484
|
+
valid(width, "width");
|
|
485
|
+
valid(height, "height");
|
|
486
|
+
const inset = o.contentInset ?? {};
|
|
487
|
+
const topInset = inset.topPx ?? 0;
|
|
488
|
+
const bottomInset = inset.bottomPx ?? 0;
|
|
489
|
+
if (
|
|
490
|
+
![topInset, bottomInset].every(value => Number.isFinite(value) && Number.isInteger(value) && value >= 0) ||
|
|
491
|
+
topInset + bottomInset >= height
|
|
492
|
+
)
|
|
493
|
+
throw new Error("Invalid GIF content inset");
|
|
494
|
+
const contentHeight = height - topInset - bottomInset;
|
|
495
|
+
if (width * height * o.timeline.length > 64 * 1024 * 1024) throw new Error("GIF allocation exceeds safety budget");
|
|
496
|
+
const paletteKeys = Object.keys(PET_SKINS[o.skin].palette).filter(k => k !== ".");
|
|
497
|
+
const palette = [[0, 0, 0], ...paletteKeys.map(k => PET_SKINS[o.skin].palette[k]!)];
|
|
498
|
+
const chunks: number[] = [
|
|
499
|
+
...Buffer.from("GIF89a"),
|
|
500
|
+
width & 255,
|
|
501
|
+
width >> 8,
|
|
502
|
+
height & 255,
|
|
503
|
+
height >> 8,
|
|
504
|
+
0xf7,
|
|
505
|
+
0,
|
|
506
|
+
0,
|
|
507
|
+
...palette.flat(),
|
|
508
|
+
...Array((256 - palette.length) * 3).fill(0),
|
|
509
|
+
33,
|
|
510
|
+
255,
|
|
511
|
+
11,
|
|
512
|
+
...Buffer.from("NETSCAPE2.0"),
|
|
513
|
+
3,
|
|
514
|
+
1,
|
|
515
|
+
0,
|
|
516
|
+
0,
|
|
517
|
+
0,
|
|
518
|
+
];
|
|
519
|
+
for (const frame of o.timeline) {
|
|
520
|
+
const pixels: number[] = [],
|
|
521
|
+
grid = PET_SKINS[o.skin].frames[frame.name];
|
|
522
|
+
if (!grid) throw new Error(`Unknown ${o.skin} GIF frame: ${frame.name}`);
|
|
523
|
+
for (let y = 0; y < height; y++)
|
|
524
|
+
for (let x = 0; x < width; x++) {
|
|
525
|
+
const contentY = y - topInset;
|
|
526
|
+
if (contentY < 0 || contentY >= contentHeight) {
|
|
527
|
+
pixels.push(0);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const sx = Math.min(15, Math.floor((x * 16) / width));
|
|
531
|
+
const sy = Math.min(15, Math.floor((contentY * 16) / contentHeight));
|
|
532
|
+
const ch = grid[sy][sx];
|
|
533
|
+
pixels.push(ch === "." ? 0 : Math.max(1, paletteKeys.indexOf(ch) + 1));
|
|
534
|
+
}
|
|
535
|
+
const delay = Math.round(frame.delayMs / 10);
|
|
536
|
+
chunks.push(
|
|
537
|
+
33,
|
|
538
|
+
249,
|
|
539
|
+
4,
|
|
540
|
+
0x09,
|
|
541
|
+
delay & 255,
|
|
542
|
+
delay >> 8,
|
|
543
|
+
0,
|
|
544
|
+
0,
|
|
545
|
+
44,
|
|
546
|
+
0,
|
|
547
|
+
0,
|
|
548
|
+
0,
|
|
549
|
+
0,
|
|
550
|
+
width & 255,
|
|
551
|
+
width >> 8,
|
|
552
|
+
height & 255,
|
|
553
|
+
height >> 8,
|
|
554
|
+
0,
|
|
555
|
+
...gifLzw(pixels),
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
chunks.push(59);
|
|
559
|
+
const bytes = Uint8Array.from(chunks);
|
|
560
|
+
const base64 = Buffer.from(bytes).toString("base64");
|
|
561
|
+
const multipart = encodeITerm2Multipart(base64, {
|
|
562
|
+
width: o.displaySize?.width ?? `${width}px`,
|
|
563
|
+
height: o.displaySize?.height ?? `${height}px`,
|
|
564
|
+
});
|
|
565
|
+
const tmuxDcs = wrapITerm2RecordsForTmux(multipart);
|
|
566
|
+
return {
|
|
567
|
+
bytes,
|
|
568
|
+
base64,
|
|
569
|
+
width,
|
|
570
|
+
height,
|
|
571
|
+
frames: [...o.timeline],
|
|
572
|
+
skin: o.skin,
|
|
573
|
+
multipart,
|
|
574
|
+
tmuxDcs,
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
const gifCache = new Map<string, GajaePetGifArtifact>();
|
|
578
|
+
let gifCacheBytes = 0;
|
|
579
|
+
let gifCacheBase64Bytes = 0;
|
|
580
|
+
let gifCacheMultipartBytes = 0;
|
|
581
|
+
let gifCacheTmuxDcsBytes = 0;
|
|
582
|
+
let gifCacheEvictions = 0;
|
|
583
|
+
const GIF_CACHE_MAX_ENTRIES = 32;
|
|
584
|
+
const GIF_CACHE_MAX_BYTES = 8 * 1024 * 1024;
|
|
585
|
+
const byteLength = (value: string): number => Buffer.byteLength(value, "utf8");
|
|
586
|
+
|
|
587
|
+
export function getGajaePetGifCached(input: GajaePetGifOptions | GajaeGifTimeline = {}): GajaePetGifArtifact {
|
|
588
|
+
const o = gifOptions(input),
|
|
589
|
+
key = JSON.stringify([
|
|
590
|
+
o.skin,
|
|
591
|
+
o.timeline,
|
|
592
|
+
o.cellWidthPx,
|
|
593
|
+
o.cellHeightPx,
|
|
594
|
+
o.targetRows,
|
|
595
|
+
o.rectangle,
|
|
596
|
+
o.displaySize,
|
|
597
|
+
o.contentInset,
|
|
598
|
+
]),
|
|
599
|
+
hit = gifCache.get(key);
|
|
600
|
+
if (hit) {
|
|
601
|
+
gifCache.delete(key);
|
|
602
|
+
gifCache.set(key, hit);
|
|
603
|
+
return hit;
|
|
604
|
+
}
|
|
605
|
+
const value = encodeGajaePetGif(o);
|
|
606
|
+
gifCache.set(key, value);
|
|
607
|
+
gifCacheBytes += value.bytes.byteLength;
|
|
608
|
+
gifCacheBase64Bytes += byteLength(value.base64);
|
|
609
|
+
gifCacheMultipartBytes += value.multipart.reduce((sum, record) => sum + byteLength(record), 0);
|
|
610
|
+
gifCacheTmuxDcsBytes += value.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0);
|
|
611
|
+
while (
|
|
612
|
+
gifCache.size > GIF_CACHE_MAX_ENTRIES ||
|
|
613
|
+
gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes > GIF_CACHE_MAX_BYTES
|
|
614
|
+
) {
|
|
615
|
+
const k = gifCache.keys().next().value as string,
|
|
616
|
+
old = gifCache.get(k)!;
|
|
617
|
+
gifCache.delete(k);
|
|
618
|
+
gifCacheBytes -= old.bytes.byteLength;
|
|
619
|
+
gifCacheBase64Bytes -= byteLength(old.base64);
|
|
620
|
+
gifCacheMultipartBytes -= old.multipart.reduce((sum, record) => sum + byteLength(record), 0);
|
|
621
|
+
gifCacheTmuxDcsBytes -= old.tmuxDcs.reduce((sum, record) => sum + byteLength(record), 0);
|
|
622
|
+
gifCacheEvictions++;
|
|
623
|
+
}
|
|
624
|
+
return value;
|
|
625
|
+
}
|
|
626
|
+
export function getGajaePetGifCacheStats(): {
|
|
627
|
+
size: number;
|
|
628
|
+
bytes: number;
|
|
629
|
+
gifBytes: number;
|
|
630
|
+
base64Bytes: number;
|
|
631
|
+
multipartBytes: number;
|
|
632
|
+
tmuxDcsBytes: number;
|
|
633
|
+
evictions: number;
|
|
634
|
+
} {
|
|
635
|
+
return {
|
|
636
|
+
size: gifCache.size,
|
|
637
|
+
bytes: gifCacheBytes + gifCacheBase64Bytes + gifCacheMultipartBytes + gifCacheTmuxDcsBytes,
|
|
638
|
+
gifBytes: gifCacheBytes,
|
|
639
|
+
base64Bytes: gifCacheBase64Bytes,
|
|
640
|
+
multipartBytes: gifCacheMultipartBytes,
|
|
641
|
+
tmuxDcsBytes: gifCacheTmuxDcsBytes,
|
|
642
|
+
evictions: gifCacheEvictions,
|
|
643
|
+
};
|
|
644
|
+
}
|
|
645
|
+
export function resetGajaePetGifCache(): void {
|
|
646
|
+
gifCache.clear();
|
|
647
|
+
gifCacheBytes = 0;
|
|
648
|
+
gifCacheBase64Bytes = 0;
|
|
649
|
+
gifCacheMultipartBytes = 0;
|
|
650
|
+
gifCacheTmuxDcsBytes = 0;
|
|
651
|
+
gifCacheEvictions = 0;
|
|
652
|
+
}
|
|
653
|
+
export const clearGajaePetGifCache = resetGajaePetGifCache;
|
|
287
654
|
/** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
|
|
288
655
|
export function encodeGridSixel(
|
|
289
656
|
grid: string[],
|
|
@@ -349,6 +716,162 @@ export function encodeGridSixel(
|
|
|
349
716
|
return `${out}\x1b\\`;
|
|
350
717
|
}
|
|
351
718
|
|
|
719
|
+
const MAX_PET_PNG_DIMENSION = 16_384;
|
|
720
|
+
const MAX_PET_PNG_RAW_BYTES = 64 * 1024 * 1024;
|
|
721
|
+
const MAX_PET_FRAME_DIMENSION = 4_096;
|
|
722
|
+
const MAX_PET_FRAME_RGBA_BYTES = 16 * 1024 * 1024;
|
|
723
|
+
|
|
724
|
+
function pngChunk(type: string, data: Uint8Array): Uint8Array {
|
|
725
|
+
const typeBytes = Buffer.from(type, "ascii");
|
|
726
|
+
const payload = Buffer.concat([typeBytes, Buffer.from(data)]);
|
|
727
|
+
let crc = 0xffffffff;
|
|
728
|
+
for (const byte of payload) {
|
|
729
|
+
crc ^= byte;
|
|
730
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
731
|
+
}
|
|
732
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
733
|
+
const out = Buffer.allocUnsafe(12 + data.length);
|
|
734
|
+
out.writeUInt32BE(data.length, 0);
|
|
735
|
+
Buffer.from(payload).copy(out, 4);
|
|
736
|
+
out.writeUInt32BE(crc, 8 + data.length);
|
|
737
|
+
return out;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function validatePngGrid(
|
|
741
|
+
grid: string[],
|
|
742
|
+
scale: number,
|
|
743
|
+
topPaddingPx: number,
|
|
744
|
+
bottomPaddingPx: number,
|
|
745
|
+
leftPaddingPx: number,
|
|
746
|
+
rightPaddingPx: number,
|
|
747
|
+
): {
|
|
748
|
+
gridWidth: number;
|
|
749
|
+
gridHeight: number;
|
|
750
|
+
spriteWidth: number;
|
|
751
|
+
width: number;
|
|
752
|
+
spriteHeight: number;
|
|
753
|
+
height: number;
|
|
754
|
+
} {
|
|
755
|
+
const gridHeight = grid.length;
|
|
756
|
+
const gridWidth = grid[0]?.length ?? 0;
|
|
757
|
+
if (gridHeight === 0 || gridWidth === 0 || grid.some(row => row.length !== gridWidth)) {
|
|
758
|
+
throw new Error("iTerm2 pet grid must be non-empty and rectangular");
|
|
759
|
+
}
|
|
760
|
+
if (!Number.isFinite(scale) || scale <= 0) throw new Error("iTerm2 pet scale must be finite and positive");
|
|
761
|
+
for (const [name, value] of [
|
|
762
|
+
["top padding", topPaddingPx],
|
|
763
|
+
["bottom padding", bottomPaddingPx],
|
|
764
|
+
["left padding", leftPaddingPx],
|
|
765
|
+
["right padding", rightPaddingPx],
|
|
766
|
+
] as const) {
|
|
767
|
+
if (!Number.isSafeInteger(value) || value < 0)
|
|
768
|
+
throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
|
|
769
|
+
}
|
|
770
|
+
const spriteWidth = Math.round(gridWidth * scale);
|
|
771
|
+
const spriteHeight = Math.round(gridHeight * scale);
|
|
772
|
+
const width = spriteWidth + leftPaddingPx + rightPaddingPx;
|
|
773
|
+
const height = spriteHeight + topPaddingPx + bottomPaddingPx;
|
|
774
|
+
if (
|
|
775
|
+
!Number.isSafeInteger(spriteWidth) ||
|
|
776
|
+
!Number.isSafeInteger(spriteHeight) ||
|
|
777
|
+
!Number.isSafeInteger(width) ||
|
|
778
|
+
!Number.isSafeInteger(height) ||
|
|
779
|
+
spriteWidth <= 0 ||
|
|
780
|
+
spriteHeight <= 0 ||
|
|
781
|
+
width <= 0 ||
|
|
782
|
+
height <= 0 ||
|
|
783
|
+
width > MAX_PET_PNG_DIMENSION ||
|
|
784
|
+
height > MAX_PET_PNG_DIMENSION
|
|
785
|
+
) {
|
|
786
|
+
throw new Error("iTerm2 pet PNG dimensions are out of bounds");
|
|
787
|
+
}
|
|
788
|
+
const stride = width * 4 + 1;
|
|
789
|
+
const rawBytes = stride * height;
|
|
790
|
+
if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
|
|
791
|
+
throw new Error("iTerm2 pet PNG allocation is out of bounds");
|
|
792
|
+
}
|
|
793
|
+
return { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height };
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* Encode a grid as an iTerm2 inline PNG spanning a terminal cell block.
|
|
798
|
+
*
|
|
799
|
+
* The escape's `width`/`height` are the reserved cell-block footprint in
|
|
800
|
+
* character cells (unitless numbers per the iTerm2 inline-images protocol).
|
|
801
|
+
* iTerm2 resolves cells against its own live font metrics, so the sprite
|
|
802
|
+
* scales with the real terminal geometry — including Retina, where iTerm2
|
|
803
|
+
* divides `Npx` values by the backing-scale factor and would render a fixed
|
|
804
|
+
* pixel box at half size.
|
|
805
|
+
*/
|
|
806
|
+
export function encodeGridIterm2(
|
|
807
|
+
grid: string[],
|
|
808
|
+
scale: number,
|
|
809
|
+
columns: number,
|
|
810
|
+
rows: number,
|
|
811
|
+
topPaddingPx = 0,
|
|
812
|
+
bottomPaddingPx = 0,
|
|
813
|
+
leftPaddingPx = 0,
|
|
814
|
+
rightPaddingPx = 0,
|
|
815
|
+
palette: Palette = RED_PALETTE,
|
|
816
|
+
): string {
|
|
817
|
+
for (const [name, value] of [
|
|
818
|
+
["column count", columns],
|
|
819
|
+
["row count", rows],
|
|
820
|
+
] as const) {
|
|
821
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
822
|
+
throw new Error(`iTerm2 pet ${name} must be a positive integer`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
const { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height } = validatePngGrid(
|
|
826
|
+
grid,
|
|
827
|
+
scale,
|
|
828
|
+
topPaddingPx,
|
|
829
|
+
bottomPaddingPx,
|
|
830
|
+
leftPaddingPx,
|
|
831
|
+
rightPaddingPx,
|
|
832
|
+
);
|
|
833
|
+
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
834
|
+
for (let y = 0; y < height; y++) {
|
|
835
|
+
for (let x = 0; x < width; x++) {
|
|
836
|
+
const sourceX = x - leftPaddingPx;
|
|
837
|
+
const sourceY = y - topPaddingPx;
|
|
838
|
+
const rgb =
|
|
839
|
+
sourceX < 0 || sourceX >= spriteWidth || sourceY < 0 || sourceY >= spriteHeight
|
|
840
|
+
? null
|
|
841
|
+
: palette[
|
|
842
|
+
grid[Math.min(gridHeight - 1, Math.floor(sourceY / scale))][
|
|
843
|
+
Math.min(gridWidth - 1, Math.floor(sourceX / scale))
|
|
844
|
+
]
|
|
845
|
+
];
|
|
846
|
+
const offset = y * (width * 4 + 1) + 1 + x * 4;
|
|
847
|
+
if (!rgb) continue;
|
|
848
|
+
raw[offset] = rgb[0];
|
|
849
|
+
raw[offset + 1] = rgb[1];
|
|
850
|
+
raw[offset + 2] = rgb[2];
|
|
851
|
+
raw[offset + 3] = 255;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const compressed = zlib.deflateSync(raw);
|
|
855
|
+
const header = Buffer.alloc(13);
|
|
856
|
+
header.writeUInt32BE(width, 0);
|
|
857
|
+
header.writeUInt32BE(height, 4);
|
|
858
|
+
header[8] = 8;
|
|
859
|
+
header[9] = 6;
|
|
860
|
+
const png = Buffer.concat([
|
|
861
|
+
Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]),
|
|
862
|
+
pngChunk("IHDR", header),
|
|
863
|
+
pngChunk("IDAT", compressed),
|
|
864
|
+
pngChunk("IEND", new Uint8Array()),
|
|
865
|
+
]);
|
|
866
|
+
// Size in character cells, not pixels: iTerm2 applies its own cell metrics
|
|
867
|
+
// (and divides px values by the Retina backing scale), so cells keep the
|
|
868
|
+
// padded canvas 1:1 with the reserved block at every geometry.
|
|
869
|
+
// iTerm uses the supplied filename when a user drags this inline image out.
|
|
870
|
+
// The unique name lets the composer discard only this pet's automatic path paste.
|
|
871
|
+
const params = `name=Z2FqYWUtcGV0LnBuZw==;width=${columns};height=${rows};preserveAspectRatio=0;inline=1`;
|
|
872
|
+
return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
|
|
873
|
+
}
|
|
874
|
+
|
|
352
875
|
/** Encode a bottom-aligned grid as kitty raw RGBA at `scale`. */
|
|
353
876
|
export function encodeGridKitty(
|
|
354
877
|
grid: string[],
|
|
@@ -407,10 +930,12 @@ export function encodeGridKitty(
|
|
|
407
930
|
|
|
408
931
|
export interface GajaePixelFrames {
|
|
409
932
|
/** escape payload per logical frame (drawn at the current cursor cell) */
|
|
410
|
-
frames: Record<
|
|
933
|
+
frames: Record<string, string>;
|
|
411
934
|
/** protocol the frames were encoded for */
|
|
412
|
-
protocol: "sixel" | "kitty";
|
|
935
|
+
protocol: "sixel" | "kitty" | "iterm2";
|
|
936
|
+
/** Scaled sprite width before transparent cell-block padding. */
|
|
413
937
|
widthPx: number;
|
|
938
|
+
/** Encoded raster height, including protocol-specific transparent padding. */
|
|
414
939
|
heightPx: number;
|
|
415
940
|
columns: number;
|
|
416
941
|
rows: number;
|
|
@@ -420,11 +945,22 @@ export interface GajaePixelFrames {
|
|
|
420
945
|
|
|
421
946
|
/**
|
|
422
947
|
* Build overlay pixel frames exactly `targetRows` terminal rows tall when the
|
|
423
|
-
* terminal cells permit it.
|
|
424
|
-
*
|
|
948
|
+
* terminal cells permit it. Each skin owns its source resolution so future
|
|
949
|
+
* additions can opt into denser art without changing the terminal footprint.
|
|
950
|
+
*
|
|
951
|
+
* Geometry contract:
|
|
952
|
+
* - `scale = max(1, targetRows * cellHeightPx / gridHeight)`
|
|
953
|
+
* - `columns = ceil(scaledSpriteWidthPx / cellWidthPx)`
|
|
954
|
+
* - `rows = ceil(scaledSpriteHeightPx / cellHeightPx)`
|
|
955
|
+
* - the square sprite is centered in a `columns * cellWidthPx` PNG canvas
|
|
956
|
+
*
|
|
957
|
+
* iTerm2 receives unitless `width=columns;height=rasterRows`, so it resolves the
|
|
958
|
+
* padded block with its live cell metrics. The PNG has that block's pixel aspect
|
|
959
|
+
* ratio, allowing `preserveAspectRatio=0` without stretching the authored square
|
|
960
|
+
* sprite. Kitty and Sixel retain their protocol-specific paths.
|
|
425
961
|
*/
|
|
426
962
|
export function buildGajaePixelFrames(options: {
|
|
427
|
-
protocol: "sixel" | "kitty";
|
|
963
|
+
protocol: "sixel" | "kitty" | "iterm2";
|
|
428
964
|
cellWidthPx: number;
|
|
429
965
|
cellHeightPx: number;
|
|
430
966
|
targetRows?: number;
|
|
@@ -433,46 +969,106 @@ export function buildGajaePixelFrames(options: {
|
|
|
433
969
|
/** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
|
|
434
970
|
kittyCellYOffsetPx?: number;
|
|
435
971
|
kittyImageId?: number;
|
|
972
|
+
/** Additional transparent iTerm2-only top padding for sub-cell vertical alignment. */
|
|
973
|
+
iterm2TopPaddingPx?: number;
|
|
974
|
+
/** Transparent iTerm2-only bottom padding inside the canvas. */
|
|
975
|
+
iterm2BottomPaddingPx?: number;
|
|
436
976
|
/** Color skin for the sprite palette (default "red"). */
|
|
437
977
|
skin?: PetSkinId;
|
|
438
978
|
}): GajaePixelFrames {
|
|
439
979
|
const targetRows = options.targetRows ?? 2;
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
980
|
+
if (!Number.isFinite(options.cellWidthPx) || options.cellWidthPx <= 0) {
|
|
981
|
+
throw new Error("Pet cell width must be finite and positive");
|
|
982
|
+
}
|
|
983
|
+
if (!Number.isFinite(options.cellHeightPx) || options.cellHeightPx <= 0) {
|
|
984
|
+
throw new Error("Pet cell height must be finite and positive");
|
|
985
|
+
}
|
|
986
|
+
if (!Number.isFinite(targetRows) || targetRows <= 0) throw new Error("Pet target rows must be finite and positive");
|
|
987
|
+
const skin = PET_SKINS[options.skin ?? "red"];
|
|
988
|
+
const grids = Object.entries(skin.frames);
|
|
989
|
+
const firstGrid = grids[0]?.[1];
|
|
990
|
+
if (!firstGrid?.[0]) throw new Error(`Pet skin ${skin.id} has no pixel frames`);
|
|
991
|
+
const gridHeight = firstGrid.length;
|
|
992
|
+
const gridWidth = firstGrid[0].length;
|
|
993
|
+
for (const [name, grid] of grids) {
|
|
994
|
+
if (grid.length !== gridHeight || grid.some(row => row.length !== gridWidth)) {
|
|
995
|
+
throw new Error(`Pet frame ${skin.id}/${name} does not match ${gridWidth}x${gridHeight}`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
const scale = Math.max(1, (targetRows * options.cellHeightPx) / gridHeight);
|
|
999
|
+
const widthPx = Math.round(gridWidth * scale);
|
|
1000
|
+
const visibleHeightPx = Math.round(gridHeight * scale);
|
|
444
1001
|
const columns = Math.ceil(widthPx / options.cellWidthPx);
|
|
445
1002
|
const rows = Math.ceil(visibleHeightPx / options.cellHeightPx);
|
|
446
1003
|
const allocatedHeightPx = rows * options.cellHeightPx;
|
|
447
1004
|
const topPaddingPx =
|
|
448
1005
|
allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
|
|
449
1006
|
const heightPx = visibleHeightPx + topPaddingPx;
|
|
450
|
-
const
|
|
1007
|
+
const kittyYOffsetPx = options.protocol === "kitty" ? Math.max(0, Math.round(options.kittyCellYOffsetPx ?? 0)) : 0;
|
|
451
1008
|
// Center the square sprite in its (cols * cellWidth) block, which the ceil()
|
|
452
1009
|
// column rounding can make wider than the sprite itself.
|
|
453
1010
|
const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
|
|
454
1011
|
const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
|
|
455
1012
|
const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
|
|
1013
|
+
const canvasWidthPx = widthPx + leftPaddingPx + rightPaddingPx;
|
|
1014
|
+
// When minimum 1x art is taller than targetRows (only possible with tiny
|
|
1015
|
+
// cells), top-pad iTerm2 to the full reserved row block. Its unitless OSC
|
|
1016
|
+
// height then maps the PNG 1:1 without vertically stretching the authored square sprite.
|
|
1017
|
+
const iterm2TopPaddingPx =
|
|
1018
|
+
allocatedHeightPx - visibleHeightPx + (options.protocol === "iterm2" ? (options.iterm2TopPaddingPx ?? 0) : 0);
|
|
1019
|
+
const protocolHeightPx =
|
|
1020
|
+
options.protocol === "iterm2"
|
|
1021
|
+
? visibleHeightPx + iterm2TopPaddingPx + (options.iterm2BottomPaddingPx ?? 0)
|
|
1022
|
+
: heightPx;
|
|
1023
|
+
const rasterRows = Math.ceil((protocolHeightPx + kittyYOffsetPx) / options.cellHeightPx);
|
|
1024
|
+
if (
|
|
1025
|
+
widthPx > MAX_PET_FRAME_DIMENSION ||
|
|
1026
|
+
heightPx > MAX_PET_FRAME_DIMENSION ||
|
|
1027
|
+
canvasWidthPx > MAX_PET_FRAME_DIMENSION ||
|
|
1028
|
+
!Number.isSafeInteger(canvasWidthPx * heightPx * 4) ||
|
|
1029
|
+
canvasWidthPx * heightPx * 4 > MAX_PET_FRAME_RGBA_BYTES
|
|
1030
|
+
) {
|
|
1031
|
+
throw new Error("Pet frame dimensions are out of bounds");
|
|
1032
|
+
}
|
|
456
1033
|
const imageId = options.kittyImageId ?? 0xc0de;
|
|
457
|
-
const
|
|
458
|
-
const
|
|
459
|
-
for (const name of Object.keys(PIXEL_GRIDS) as GajaePixelFrameName[]) {
|
|
1034
|
+
const frames: Record<string, string> = {};
|
|
1035
|
+
for (const [name, grid] of grids) {
|
|
460
1036
|
frames[name] =
|
|
461
1037
|
options.protocol === "sixel"
|
|
462
|
-
? encodeGridSixel(
|
|
463
|
-
:
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
1038
|
+
? encodeGridSixel(grid, scale, topPaddingPx, skin.palette)
|
|
1039
|
+
: options.protocol === "iterm2"
|
|
1040
|
+
? encodeGridIterm2(
|
|
1041
|
+
grid,
|
|
1042
|
+
scale,
|
|
1043
|
+
columns,
|
|
1044
|
+
rasterRows,
|
|
1045
|
+
iterm2TopPaddingPx,
|
|
1046
|
+
options.iterm2BottomPaddingPx ?? 0,
|
|
1047
|
+
leftPaddingPx,
|
|
1048
|
+
rightPaddingPx,
|
|
1049
|
+
skin.palette,
|
|
1050
|
+
)
|
|
1051
|
+
: encodeGridKitty(
|
|
1052
|
+
grid,
|
|
1053
|
+
scale,
|
|
1054
|
+
imageId,
|
|
1055
|
+
columns,
|
|
1056
|
+
rows,
|
|
1057
|
+
topPaddingPx,
|
|
1058
|
+
options.kittyCellYOffsetPx ?? 0,
|
|
1059
|
+
leftPaddingPx,
|
|
1060
|
+
rightPaddingPx,
|
|
1061
|
+
skin.palette,
|
|
1062
|
+
);
|
|
475
1063
|
}
|
|
476
1064
|
|
|
477
|
-
return {
|
|
1065
|
+
return {
|
|
1066
|
+
frames,
|
|
1067
|
+
protocol: options.protocol,
|
|
1068
|
+
widthPx,
|
|
1069
|
+
heightPx: protocolHeightPx,
|
|
1070
|
+
columns,
|
|
1071
|
+
rows,
|
|
1072
|
+
rasterRows,
|
|
1073
|
+
};
|
|
478
1074
|
}
|