@gajae-code/tui 0.13.3 → 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 +16 -0
- package/dist/types/components/gajae-pet.d.ts +86 -5
- 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 +381 -26
- package/src/terminal-capabilities.ts +137 -0
- package/src/terminal.ts +33 -0
- package/src/tui.ts +989 -106
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as zlib from "node:zlib";
|
|
2
2
|
|
|
3
|
+
import { encodeITerm2Multipart, wrapITerm2RecordsForTmux } from "../terminal-capabilities";
|
|
3
4
|
import {
|
|
4
5
|
OUROBOROS_HEART_STEPS,
|
|
5
6
|
OUROBOROS_IDLE_STEPS,
|
|
@@ -231,7 +232,8 @@ const PIXEL_GRIDS: Record<GajaePixelFrameName, string[]> = {
|
|
|
231
232
|
};
|
|
232
233
|
|
|
233
234
|
/** Para-para work dance beats: the working loop and each skin's burst "work-in" intro. */
|
|
234
|
-
export
|
|
235
|
+
export type GajaeGifFrameTuple = readonly [GajaePixelFrameName, number];
|
|
236
|
+
export const PARA_PARA_STEPS: readonly GajaeGifFrameTuple[] = [
|
|
235
237
|
["danceL", 300],
|
|
236
238
|
["danceR", 300],
|
|
237
239
|
["base", 260],
|
|
@@ -329,19 +331,19 @@ export const PET_SKINS: Record<PetSkinId, PetSkin> = {
|
|
|
329
331
|
|
|
330
332
|
/** Total burst duration (intro beats plus the looping tail). */
|
|
331
333
|
export function petBurstDurationMs(burst: PetBurst): number {
|
|
332
|
-
const introMs = burst.intro.reduce((sum, [,
|
|
334
|
+
const introMs = burst.intro.reduce((sum, [, delayMs]) => sum + delayMs, 0);
|
|
333
335
|
return introMs + (burst.tail?.ms ?? 0);
|
|
334
336
|
}
|
|
335
337
|
|
|
336
338
|
/** The frame to show `elapsed` ms into a burst (`now` cycles the looping tail). */
|
|
337
339
|
export function petBurstFrame(burst: PetBurst, elapsed: number, now: number): PetFrameName {
|
|
338
340
|
let t = elapsed;
|
|
339
|
-
for (const [
|
|
340
|
-
if (t <
|
|
341
|
-
t -=
|
|
341
|
+
for (const [name, delayMs] of burst.intro) {
|
|
342
|
+
if (t < delayMs) return name;
|
|
343
|
+
t -= delayMs;
|
|
342
344
|
}
|
|
343
345
|
const tail = burst.tail;
|
|
344
|
-
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";
|
|
345
347
|
return tail.frames[Math.floor(now / tail.stepMs) % tail.frames.length];
|
|
346
348
|
}
|
|
347
349
|
|
|
@@ -354,6 +356,301 @@ export const __gajaePetTestHooks = {
|
|
|
354
356
|
},
|
|
355
357
|
};
|
|
356
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;
|
|
357
654
|
/** Encode a grid as a transparent SIXEL image, optionally bottom-aligned by top padding. */
|
|
358
655
|
export function encodeGridSixel(
|
|
359
656
|
grid: string[],
|
|
@@ -445,9 +742,12 @@ function validatePngGrid(
|
|
|
445
742
|
scale: number,
|
|
446
743
|
topPaddingPx: number,
|
|
447
744
|
bottomPaddingPx: number,
|
|
745
|
+
leftPaddingPx: number,
|
|
746
|
+
rightPaddingPx: number,
|
|
448
747
|
): {
|
|
449
748
|
gridWidth: number;
|
|
450
749
|
gridHeight: number;
|
|
750
|
+
spriteWidth: number;
|
|
451
751
|
width: number;
|
|
452
752
|
spriteHeight: number;
|
|
453
753
|
height: number;
|
|
@@ -461,19 +761,24 @@ function validatePngGrid(
|
|
|
461
761
|
for (const [name, value] of [
|
|
462
762
|
["top padding", topPaddingPx],
|
|
463
763
|
["bottom padding", bottomPaddingPx],
|
|
764
|
+
["left padding", leftPaddingPx],
|
|
765
|
+
["right padding", rightPaddingPx],
|
|
464
766
|
] as const) {
|
|
465
767
|
if (!Number.isSafeInteger(value) || value < 0)
|
|
466
768
|
throw new Error(`iTerm2 pet ${name} must be a non-negative integer`);
|
|
467
769
|
}
|
|
468
|
-
const
|
|
770
|
+
const spriteWidth = Math.round(gridWidth * scale);
|
|
469
771
|
const spriteHeight = Math.round(gridHeight * scale);
|
|
772
|
+
const width = spriteWidth + leftPaddingPx + rightPaddingPx;
|
|
470
773
|
const height = spriteHeight + topPaddingPx + bottomPaddingPx;
|
|
471
774
|
if (
|
|
472
|
-
!Number.isSafeInteger(
|
|
775
|
+
!Number.isSafeInteger(spriteWidth) ||
|
|
473
776
|
!Number.isSafeInteger(spriteHeight) ||
|
|
777
|
+
!Number.isSafeInteger(width) ||
|
|
474
778
|
!Number.isSafeInteger(height) ||
|
|
475
|
-
|
|
779
|
+
spriteWidth <= 0 ||
|
|
476
780
|
spriteHeight <= 0 ||
|
|
781
|
+
width <= 0 ||
|
|
477
782
|
height <= 0 ||
|
|
478
783
|
width > MAX_PET_PNG_DIMENSION ||
|
|
479
784
|
height > MAX_PET_PNG_DIMENSION
|
|
@@ -485,33 +790,57 @@ function validatePngGrid(
|
|
|
485
790
|
if (!Number.isSafeInteger(rawBytes) || rawBytes > MAX_PET_PNG_RAW_BYTES) {
|
|
486
791
|
throw new Error("iTerm2 pet PNG allocation is out of bounds");
|
|
487
792
|
}
|
|
488
|
-
return { gridWidth, gridHeight,
|
|
793
|
+
return { gridWidth, gridHeight, spriteWidth, spriteHeight, width, height };
|
|
489
794
|
}
|
|
490
795
|
|
|
491
|
-
/**
|
|
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
|
+
*/
|
|
492
806
|
export function encodeGridIterm2(
|
|
493
807
|
grid: string[],
|
|
494
808
|
scale: number,
|
|
809
|
+
columns: number,
|
|
810
|
+
rows: number,
|
|
495
811
|
topPaddingPx = 0,
|
|
496
812
|
bottomPaddingPx = 0,
|
|
813
|
+
leftPaddingPx = 0,
|
|
814
|
+
rightPaddingPx = 0,
|
|
497
815
|
palette: Palette = RED_PALETTE,
|
|
498
816
|
): string {
|
|
499
|
-
const
|
|
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(
|
|
500
826
|
grid,
|
|
501
827
|
scale,
|
|
502
828
|
topPaddingPx,
|
|
503
829
|
bottomPaddingPx,
|
|
830
|
+
leftPaddingPx,
|
|
831
|
+
rightPaddingPx,
|
|
504
832
|
);
|
|
505
833
|
const raw = Buffer.alloc((width * 4 + 1) * height);
|
|
506
834
|
for (let y = 0; y < height; y++) {
|
|
507
835
|
for (let x = 0; x < width; x++) {
|
|
836
|
+
const sourceX = x - leftPaddingPx;
|
|
508
837
|
const sourceY = y - topPaddingPx;
|
|
509
838
|
const rgb =
|
|
510
|
-
sourceY < 0 || sourceY >= spriteHeight
|
|
839
|
+
sourceX < 0 || sourceX >= spriteWidth || sourceY < 0 || sourceY >= spriteHeight
|
|
511
840
|
? null
|
|
512
841
|
: palette[
|
|
513
842
|
grid[Math.min(gridHeight - 1, Math.floor(sourceY / scale))][
|
|
514
|
-
Math.min(gridWidth - 1, Math.floor(
|
|
843
|
+
Math.min(gridWidth - 1, Math.floor(sourceX / scale))
|
|
515
844
|
]
|
|
516
845
|
];
|
|
517
846
|
const offset = y * (width * 4 + 1) + 1 + x * 4;
|
|
@@ -534,10 +863,12 @@ export function encodeGridIterm2(
|
|
|
534
863
|
pngChunk("IDAT", compressed),
|
|
535
864
|
pngChunk("IEND", new Uint8Array()),
|
|
536
865
|
]);
|
|
537
|
-
//
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
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`;
|
|
541
872
|
return `\x1b]1337;File=${params}:${png.toString("base64")}\x1b\\`;
|
|
542
873
|
}
|
|
543
874
|
|
|
@@ -602,7 +933,9 @@ export interface GajaePixelFrames {
|
|
|
602
933
|
frames: Record<string, string>;
|
|
603
934
|
/** protocol the frames were encoded for */
|
|
604
935
|
protocol: "sixel" | "kitty" | "iterm2";
|
|
936
|
+
/** Scaled sprite width before transparent cell-block padding. */
|
|
605
937
|
widthPx: number;
|
|
938
|
+
/** Encoded raster height, including protocol-specific transparent padding. */
|
|
606
939
|
heightPx: number;
|
|
607
940
|
columns: number;
|
|
608
941
|
rows: number;
|
|
@@ -614,6 +947,17 @@ export interface GajaePixelFrames {
|
|
|
614
947
|
* Build overlay pixel frames exactly `targetRows` terminal rows tall when the
|
|
615
948
|
* terminal cells permit it. Each skin owns its source resolution so future
|
|
616
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.
|
|
617
961
|
*/
|
|
618
962
|
export function buildGajaePixelFrames(options: {
|
|
619
963
|
protocol: "sixel" | "kitty" | "iterm2";
|
|
@@ -625,9 +969,9 @@ export function buildGajaePixelFrames(options: {
|
|
|
625
969
|
/** Native sub-cell `Y=` pixel offset that drops the kitty sprite within its first cell. */
|
|
626
970
|
kittyCellYOffsetPx?: number;
|
|
627
971
|
kittyImageId?: number;
|
|
628
|
-
/**
|
|
972
|
+
/** Additional transparent iTerm2-only top padding for sub-cell vertical alignment. */
|
|
629
973
|
iterm2TopPaddingPx?: number;
|
|
630
|
-
/** Transparent iTerm2-only bottom padding inside the
|
|
974
|
+
/** Transparent iTerm2-only bottom padding inside the canvas. */
|
|
631
975
|
iterm2BottomPaddingPx?: number;
|
|
632
976
|
/** Color skin for the sprite palette (default "red"). */
|
|
633
977
|
skin?: PetSkinId;
|
|
@@ -660,12 +1004,23 @@ export function buildGajaePixelFrames(options: {
|
|
|
660
1004
|
const topPaddingPx =
|
|
661
1005
|
allocatedHeightPx - visibleHeightPx + (options.protocol === "sixel" ? (options.sixelTopPaddingPx ?? 0) : 0);
|
|
662
1006
|
const heightPx = visibleHeightPx + topPaddingPx;
|
|
1007
|
+
const kittyYOffsetPx = options.protocol === "kitty" ? Math.max(0, Math.round(options.kittyCellYOffsetPx ?? 0)) : 0;
|
|
663
1008
|
// Center the square sprite in its (cols * cellWidth) block, which the ceil()
|
|
664
1009
|
// column rounding can make wider than the sprite itself.
|
|
665
1010
|
const horizontalPaddingPx = Math.max(0, columns * options.cellWidthPx - widthPx);
|
|
666
1011
|
const leftPaddingPx = Math.floor(horizontalPaddingPx / 2);
|
|
667
1012
|
const rightPaddingPx = horizontalPaddingPx - leftPaddingPx;
|
|
668
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);
|
|
669
1024
|
if (
|
|
670
1025
|
widthPx > MAX_PET_FRAME_DIMENSION ||
|
|
671
1026
|
heightPx > MAX_PET_FRAME_DIMENSION ||
|
|
@@ -685,8 +1040,12 @@ export function buildGajaePixelFrames(options: {
|
|
|
685
1040
|
? encodeGridIterm2(
|
|
686
1041
|
grid,
|
|
687
1042
|
scale,
|
|
688
|
-
|
|
1043
|
+
columns,
|
|
1044
|
+
rasterRows,
|
|
1045
|
+
iterm2TopPaddingPx,
|
|
689
1046
|
options.iterm2BottomPaddingPx ?? 0,
|
|
1047
|
+
leftPaddingPx,
|
|
1048
|
+
rightPaddingPx,
|
|
690
1049
|
skin.palette,
|
|
691
1050
|
)
|
|
692
1051
|
: encodeGridKitty(
|
|
@@ -703,10 +1062,6 @@ export function buildGajaePixelFrames(options: {
|
|
|
703
1062
|
);
|
|
704
1063
|
}
|
|
705
1064
|
|
|
706
|
-
const protocolHeightPx =
|
|
707
|
-
options.protocol === "iterm2"
|
|
708
|
-
? visibleHeightPx + (options.iterm2TopPaddingPx ?? 0) + (options.iterm2BottomPaddingPx ?? 0)
|
|
709
|
-
: heightPx;
|
|
710
1065
|
return {
|
|
711
1066
|
frames,
|
|
712
1067
|
protocol: options.protocol,
|
|
@@ -714,6 +1069,6 @@ export function buildGajaePixelFrames(options: {
|
|
|
714
1069
|
heightPx: protocolHeightPx,
|
|
715
1070
|
columns,
|
|
716
1071
|
rows,
|
|
717
|
-
rasterRows
|
|
1072
|
+
rasterRows,
|
|
718
1073
|
};
|
|
719
1074
|
}
|