Skip to content

Commit ff8d3fc

Browse files
committed
fix: Image note rendered between frames
1 parent ae70bb7 commit ff8d3fc

3 files changed

Lines changed: 59 additions & 26 deletions

File tree

src/components/image-preview.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { iconFor } from "../lib/icons";
1414
import {
1515
type DecodedImage,
1616
kittyDelete,
17-
kittyImage,
17+
kittyPut,
18+
kittyTransmit,
1819
loadImage,
1920
pickProtocol,
2021
} from "../lib/image";
@@ -110,6 +111,19 @@ function cellPixels(renderer: ReturnType<typeof useRenderer>): {
110111

111112
type Cells = { cols: number; rows: number };
112113

114+
// OpenTUI writes every frame through its own serialized output path
115+
// (renderer.writeOut → native lib.writeOut). Writing kitty escapes via
116+
// process.stdout instead races that path at the byte level and the sequence
117+
// can arrive interleaved/corrupt — the image then intermittently fails to
118+
// show. Route our escapes through the same path so they can't interleave.
119+
// `writeOut` is internal (not in the public typings) but is the method the
120+
// renderer uses for all of its own output.
121+
type RawWriter = { writeOut(chunk: string): void };
122+
123+
function writeRaw(renderer: ReturnType<typeof useRenderer>, seq: string): void {
124+
(renderer as unknown as RawWriter).writeOut(seq);
125+
}
126+
113127
export function ImagePreview({ node }: { node: FileNode }) {
114128
const renderer = useRenderer();
115129
const containerRef = useRef<BoxRenderable>(null);
@@ -164,34 +178,36 @@ export function ImagePreview({ node }: { node: FileNode }) {
164178
};
165179
}, [node.path, cells, protocol, renderer]);
166180

167-
// Kitty path: transmit + display the image over the pane, re-placing it only
168-
// when its on-screen geometry changes. Cleanup deletes the image so it
169-
// doesn't linger when switching files or unmounting.
181+
// Kitty path: transmit the pixels once, display the placement immediately, and
182+
// keep the placement in sync as the pane moves. Cleanup deletes the image so
183+
// it doesn't linger when switching files or unmounting.
170184
useEffect(() => {
171185
if (protocol !== "kitty" || !image) return;
172-
let currentId: number | null = null;
173-
let lastKey = "";
186+
const { id, sequence } = kittyTransmit(image);
187+
writeRaw(renderer, sequence);
174188

175189
const place = () => {
176190
const box = containerRef.current;
177191
if (!box || box.width <= 0 || box.height <= 0) return;
178-
const key = `${box.x},${box.y},${box.width},${box.height}`;
179-
if (key === lastKey) return;
180-
lastKey = key;
181-
182-
let out = "";
183-
if (currentId !== null) out += kittyDelete(currentId);
184-
const placed = kittyImage(image);
185-
currentId = placed.id;
186-
// Save cursor, jump to the pane's top-left (1-based), emit, restore.
187-
out += `\x1b7\x1b[${box.y + 1};${box.x + 1}H${placed.sequence}\x1b8`;
188-
process.stdout.write(out);
192+
// Save cursor, jump to the pane's top-left (1-based), place, restore.
193+
writeRaw(
194+
renderer,
195+
`\x1b7\x1b[${box.y + 1};${box.x + 1}H${kittyPut(id)}\x1b8`,
196+
);
189197
};
190198

199+
// Display it now — layout has already settled (the pane was measured before
200+
// we got here), so the geometry is valid. We must NOT rely on a FRAME event
201+
// to show it: OpenTUI renders on demand and stays idle once the pane
202+
// settles, so a frame often never arrives and the image would never appear.
203+
// Redrawn cells don't erase kitty images, so one placement keeps it visible;
204+
// the FRAME listener only re-places when the pane moves (scroll/resize),
205+
// which do trigger renders.
206+
place();
191207
renderer.on(CliRenderEvents.FRAME, place);
192208
return () => {
193209
renderer.off(CliRenderEvents.FRAME, place);
194-
if (currentId !== null) process.stdout.write(kittyDelete(currentId));
210+
writeRaw(renderer, kittyDelete(id));
195211
};
196212
}, [protocol, image, renderer]);
197213

src/components/preview.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,8 +153,10 @@ export function Preview({ node }: { node: FileNode }) {
153153
}
154154

155155
// Images read as "binary" (NUL bytes) — render them instead of a placeholder.
156+
// Key on the path so switching image→image remounts (fresh decode + display)
157+
// rather than reusing the instance, which leaves the previous image on screen.
156158
if (preview.kind === "binary" && isImage(node.name)) {
157-
return <ImagePreview node={node} />;
159+
return <ImagePreview key={node.path} node={node} />;
158160
}
159161

160162
const message =

src/lib/image.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,19 @@ export function pickProtocol(caps: TerminalCapabilities | null): ImageProtocol {
7474
// ---------------------------------------------------------------------------
7575
// Kitty graphics protocol
7676
//
77-
// We transmit the raw RGBA (f=32), zlib-compressed (o=z), chunked into 4 KiB
78-
// base64 payloads, and display it at the cursor (a=T) without moving the cursor
79-
// (C=1). The caller positions the cursor over the preview pane first. Kitty
80-
// draws the image above the cell background but below glyphs, so it shows
81-
// through the pane's empty space cells.
77+
// Transmit and display are split into two steps so the display can be re-asserted
78+
// cheaply every frame (see ImagePreview):
79+
// - kittyTransmit: send the raw RGBA (f=32), zlib-compressed (o=z), chunked into
80+
// 4 KiB base64 payloads, stored under an image id (a=t) without displaying.
81+
// - kittyPut: create/replace a placement of that image (a=p) at the cursor,
82+
// without moving the cursor (C=1). Uses a fixed placement id (p=1) so
83+
// re-emitting it every frame replaces the placement in place rather than
84+
// stacking new ones. The caller positions the cursor over the pane first.
85+
//
86+
// Kitty draws the image above the cell background but below glyphs, so it shows
87+
// through the pane's empty-space cells. Re-asserting the placement each frame
88+
// makes it self-healing: any redraw/scroll that would otherwise clear it (e.g.
89+
// the larger repaint when switching between two images) is immediately undone.
8290
// ---------------------------------------------------------------------------
8391

8492
let nextKittyId = 1;
@@ -88,7 +96,8 @@ export interface KittyImage {
8896
sequence: string;
8997
}
9098

91-
export function kittyImage(img: DecodedImage): KittyImage {
99+
// Transmit (but don't display) an image, returning its id and the escape bytes.
100+
export function kittyTransmit(img: DecodedImage): KittyImage {
92101
const id = nextKittyId++;
93102
const compressed = deflateSync(Buffer.from(img.rgba));
94103
const b64 = compressed.toString("base64");
@@ -100,7 +109,7 @@ export function kittyImage(img: DecodedImage): KittyImage {
100109
const first = i === 0;
101110
const last = i + CHUNK >= b64.length;
102111
const control = first
103-
? `a=T,f=32,o=z,i=${id},s=${img.width},v=${img.height},C=1,q=2,m=${last ? 0 : 1}`
112+
? `a=t,f=32,o=z,i=${id},s=${img.width},v=${img.height},q=2,m=${last ? 0 : 1}`
104113
: `m=${last ? 0 : 1}`;
105114
parts.push(`\x1b_G${control};${chunk}\x1b\\`);
106115
}
@@ -109,6 +118,12 @@ export function kittyImage(img: DecodedImage): KittyImage {
109118
return { id, sequence: parts.join("") };
110119
}
111120

121+
// Create/replace the on-screen placement of a transmitted image at the cursor.
122+
// Cheap (no pixel data) so it can be re-emitted every frame.
123+
export function kittyPut(id: number): string {
124+
return `\x1b_Ga=p,i=${id},p=1,C=1,q=2\x1b\\`;
125+
}
126+
112127
// Delete a previously transmitted image (and free its data) by id.
113128
export function kittyDelete(id: number): string {
114129
return `\x1b_Ga=d,d=I,i=${id},q=2\x1b\\`;

0 commit comments

Comments
 (0)