File size: 3,310 Bytes
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
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);
    };
  }, []);
}