codev / src /ink-picture /hooks /useDirectRenderer.ts
chenbhao's picture
refactor: integrate ink-picture as source directory
7e3630c
Raw
History Blame Contribute Delete
3.31 kB
import { useCallback, useEffect, useLayoutEffect, useRef } from "react";
import { useOnRender } from "../InkPictureProvider.js";
import { cursorForward, cursorUp } from "../utils/ansiEscapes.js";
import bgColorize from "../utils/bgColorize.js";
import type { Position } from "./usePosition.js";
import { defaultVisibility } from "./useVisibility.js";
interface DirectRendererOptions {
enabled: boolean;
imageOutput: string;
position: Position | undefined;
stdout: NodeJS.WriteStream;
width: number;
height: number;
backgroundColor?: string;
}
function writeImageToStdout(
pos: Position,
output: string,
stream: NodeJS.WriteStream,
w: number,
h: number,
): { row: number; col: number; width: number; height: number } {
stream.write("\x1b7");
stream.write(
cursorUp(pos.appHeight - pos.row, {
appHeight: pos.appHeight,
terminalHeight: stream.rows,
}),
);
stream.write("\r");
stream.write(cursorForward(pos.col));
stream.write(output);
stream.write("\x1b8");
return {
row: stream.rows - pos.appHeight + pos.row,
col: pos.col,
width: w,
height: h,
};
}
export function useDirectRenderer(options: DirectRendererOptions) {
const { position, stdout, backgroundColor } = options;
const shouldCleanupRef = useRef(true);
const previousBboxRef = useRef<
{ row: number; col: number; width: number; height: number } | undefined
>(undefined);
const optionsRef = useRef(options);
optionsRef.current = options;
const writeNow = useCallback(() => {
const opt = optionsRef.current;
if (!opt.enabled || !opt.position) return;
if (
defaultVisibility(opt.position, opt.stdout.rows, opt.stdout.columns) !==
"full"
)
return;
previousBboxRef.current = writeImageToStdout(
opt.position,
opt.imageOutput,
opt.stdout,
opt.width,
opt.height,
);
}, []);
// Repaint after every React render commit, since Ink clears the terminal
// on each render cycle, wiping any direct-to-stdout image output.
useOnRender(writeNow);
useLayoutEffect(() => {
return () => {
if (!shouldCleanupRef.current) return;
const bbox = previousBboxRef.current;
if (!bbox || !position) return;
stdout.write("\x1b7");
stdout.write(
cursorUp(position.appHeight - position.row, {
appHeight: position.appHeight,
terminalHeight: stdout.rows,
}),
);
for (let i = 0; i < bbox.height; i++) {
stdout.write("\r");
stdout.write(cursorForward(bbox.col));
if (backgroundColor) {
stdout.write(bgColorize(" ".repeat(bbox.width), backgroundColor));
} else {
stdout.write(" ".repeat(bbox.width));
}
stdout.write("\n");
}
stdout.write("\x1b8");
};
});
useEffect(() => {
function onExit() {
shouldCleanupRef.current = false;
}
function onSigInt() {
shouldCleanupRef.current = false;
process.exit();
}
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);
};
}, []);
}