File size: 5,240 Bytes
7e3630c
f0588e7
7e3630c
f0588e7
 
7e3630c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
862182b
7e3630c
 
 
 
 
f0588e7
7e3630c
862182b
 
 
7e3630c
 
 
862182b
 
7e3630c
 
 
 
 
f0588e7
 
 
7e3630c
f0588e7
 
7e3630c
 
 
 
 
 
f0588e7
7e3630c
f0588e7
7e3630c
f0588e7
7e3630c
f0588e7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7e3630c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f0588e7
 
7e3630c
f0588e7
7e3630c
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
import { useStdout } from "src/ink";
import fs from "node:fs";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useOnRender } from "../../InkPictureProvider.js";
import { cursorForward } from "../../utils/ansiEscapes.js";
import { useImage } from "../../hooks/useImage.js";
import { useMeasuredSize } from "../../hooks/useMeasuredSize.js";
import usePosition from "../../hooks/usePosition.js";
import { defaultVisibility } from "../../hooks/useVisibility.js";
import { useTerminalInfo } from "../../InkPictureProvider.js";
import {
  makeKittyDeletion,
  makeKittyPlacement,
  makeKittyTransmitChunks,
} from "../../renderers/kitty.js";
import generateKittyId from "../../utils/generateKittyId.js";
import ImageBox from "../ImageBox.js";
import type { ImageProps } from "./protocol.js";

function KittyImage(props: ImageProps) {
  const terminalInfo = useTerminalInfo();
  const { stdout } = useStdout();
  const { src, width, height, pixelWidth, pixelHeight, alt } = props;

  const { containerRef, resolvedWidth, resolvedHeight } = useMeasuredSize(
    width,
    height,
  );
  const position = usePosition(containerRef);

  // Use external pixel dimensions if provided, otherwise compute from chars
  const actualPixelWidth = pixelWidth ?? resolvedWidth * (terminalInfo?.cellWidth ?? 0);
  const actualPixelHeight = pixelHeight ?? resolvedHeight * (terminalInfo?.cellHeight ?? 0);

  const { imageData, error } = useImage({
    src,
    pixelWidth: actualPixelWidth,
    pixelHeight: actualPixelHeight,
    mode: "png",
  });

  const [imageId, setImageId] = useState<number | undefined>(undefined);
  const shouldCleanupRef = useRef(true);
  const imageIdRef = useRef<number | undefined>(undefined);
  const dimsRef = useRef({ w: resolvedWidth, h: resolvedHeight });
  dimsRef.current = { w: resolvedWidth, h: resolvedHeight };

  // One-time transmit: store image data in terminal GPU memory.
  // Uses fs.writeSync to process.stdout.fd to bypass Ink's stdout buffering.
  useEffect(() => {
    if (!imageData) return;

    const id = generateKittyId();
    const base64Data = imageData.data.toString("base64");
    const chunks = makeKittyTransmitChunks(id, base64Data);
    const fd = process.stdout.fd;
    for (const chunk of chunks) {
      fs.writeSync(fd, chunk);
    }
    imageIdRef.current = id;
    setImageId(id);
  }, [imageData]);

  // Place the image using position-aware cursor movement. This uses the
  // same logic as ink-picture's writeImageToStdout / useDirectRenderer:
  // it saves cursor → cursorUp(appHeight - row) → CR → cursorForward(col)
  // → Kitty placement → restores cursor.
  //
  // The cursorUp formula accounts for Ink's trailing-newline quirk: when
  // content fills the viewport (appHeight >= terminalHeight) Ink omits the
  // extra newline, so we subtract 1 from the movement count.
  useOnRender(() => {
    const id = imageIdRef.current;
    if (!id) return;
    const pos = position;
    if (!pos) return;
    const { w, h } = dimsRef.current;
    if (h <= 0) return;

    // Skip if the image is not fully visible in the viewport
    const visibility = defaultVisibility(pos, stdout.rows, stdout.columns);
    if (visibility !== "full") return;

    // Calculate cursor-up distance using the same logic as cursorUp() in
    // ansiEscapes.ts, inlined here to avoid importing the helper.
    const appHeight = pos.appHeight;
    const terminalHeight = stdout.rows;
    const cursorUpCount = appHeight - pos.row;
    const movementCount =
      appHeight >= terminalHeight ? cursorUpCount - 1 : cursorUpCount;
    if (movementCount <= 0) return;

    // Write directly to the terminal fd, bypassing Ink's stdout (which may
    // buffer, intercept, or be overwritten by Ink's screen refresh cycle).
    const fd = process.stdout.fd;
    const buf = Buffer.concat([
      Buffer.from(`\x1b7`),                           // save cursor (DECSC)
      Buffer.from(`\x1b[${movementCount}A`),          // cursor up to image row
      Buffer.from(`\r`),                               // carriage return to col 0
      Buffer.from(cursorForward(pos.col)),             // forward to image column
      Buffer.from(makeKittyPlacement(id, 1, w, h)),
      Buffer.from(`\x1b8`),                           // restore cursor (DECRC)
    ]);
    fs.writeSync(fd, buf);
  });

  const onExit = useCallback(() => {
    shouldCleanupRef.current = false;
  }, []);

  const onSigInt = useCallback(() => {
    shouldCleanupRef.current = false;
    process.exit();
  }, []);

  useEffect(() => {
    process.on("exit", onExit);
    process.on("SIGINT", onSigInt);
    process.on("SIGTERM", onSigInt);

    return () => {
      process.removeListener("exit", onExit);
      process.removeListener("SIGINT", onSigInt);
      process.removeListener("SIGTERM", onSigInt);
      if (!shouldCleanupRef.current) return;
      if (!imageId) return;
      const fd = process.stdout.fd;
      fs.writeSync(fd, makeKittyDeletion(imageId));
    };
  }, [imageId, onExit, onSigInt]);

  return (
    <ImageBox
      ref={containerRef}
      width={width}
      height={height}
      alt={alt}
      error={error}
      loaded={!!imageId}
    />
  );
}

export default KittyImage;