Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,8 @@ Using ink-uplot in your project? Open a PR to add it here.

- Node.js >= 18
- System libraries for [node-canvas](https://github.com/Automattic/node-canvas#compiling) (Cairo, Pango)
- Ink >= 7 for exact placement of native graphics (kitty, iTerm2/VS Code, sixel): images are written after Ink flushes the frame they belong to (`waitUntilRenderFlush`). Older Ink falls back to waiting one frame interval.
- For iTerm2-protocol images (including VS Code), render with Ink's `incrementalRendering: true` so unrelated updates don't repaint — and erase — the chart's lines.

## License

Expand Down
70 changes: 58 additions & 12 deletions src/InkUPlot.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useState, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { Box, Text, useStdout, type DOMElement } from 'ink';
import { Box, Text, useApp, useStdout, type DOMElement } from 'ink';
import { renderToImageData, renderToPNG } from './renderer.js';
import { pixelsToTerminal } from './chafa.js';
import { computeScales, buildYLabels, buildXLabelLine } from './axes.js';
Expand Down Expand Up @@ -43,12 +43,17 @@ function boxScreenGeom(
* at an absolute terminal row — keeps the image in place when the frame doesn't start at
* the terminal's first row (non-fullscreen hosts, output below a shell prompt).
*/
function linesUpFromCursor(
function cursorAnchor(
geom: { row: number; frameHeight: number },
stdout: { isTTY?: boolean; rows?: number },
): number {
): { up: number; minUp: number } {
const fullscreen = Boolean(stdout.isTTY) && stdout.rows !== undefined && geom.frameHeight >= stdout.rows;
return geom.frameHeight - geom.row - (fullscreen ? 1 : 0);
return {
up: geom.frameHeight - geom.row - (fullscreen ? 1 : 0),
// Lowest on-screen frame line, counted up from the cursor (fullscreen: the cursor's own
// line; otherwise the line above it). Anything below is outside the frame.
minUp: fullscreen ? 0 : 1,
};
}

// Kitty image ids are global to the terminal window. Give each chart its own pair (for the
Expand Down Expand Up @@ -150,14 +155,21 @@ export function InkUPlot({
const [output, setOutput] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const { stdout: inkStdout } = useStdout();
// Ink >= 7 resolves this once the frame being rendered has been written to the terminal.
const app = useApp() as { waitUntilRenderFlush?: () => Promise<void> };
const [kittyIds] = useState(allocateKittyIds);
const kittyIdRef = useRef(0); // index into kittyIds of the id the next image uses
// Reserved box for out-of-band graphics — we read its on-screen position to place the image.
const boxRef = useRef<DOMElement>(null);
// Last inline image (iterm2/sixels) + where it was stamped, in 1-based cells.
// Drives the redraw interval (survive Ink repaints) and the unmount clear.
// `up`/`col` are relative to the cursor Ink leaves after a frame (see linesUpFromCursor).
const inlineStampRef = useRef<{ ansi: string; up: number; col: number; rows: number; cols: number } | null>(null);
// `up`/`col` are relative to the cursor Ink leaves after a frame (see cursorAnchor).
// termCols/termRows: the terminal size when stamped — after a resize the stamp's
// coordinates are stale (the terminal reflowed and the host repainted).
const inlineStampRef = useRef<{
ansi: string; up: number; minUp: number; col: number; rows: number; cols: number;
termCols?: number; termRows?: number;
} | null>(null);

// Inline images live in the terminal's text cells: printing anything into a cell (an Ink
// repaint of that line) drops that part of the image. Real iTerm2/sixels get a redraw
Expand All @@ -182,14 +194,31 @@ export function InkUPlot({
// and would wipe the replacement view; Ink's incremental rendering never repaints the
// unchanged lines.) Cells Ink leaves unchanged were the chart's blank placeholder, so
// default-attribute spaces are exactly right there.
// The blank runs before Ink writes its next frame, so the stamp's cursor-relative
// coordinates stay valid across most resizes — except when Ink cleared the screen (width
// shrink: it also reflowed) or the anchor moved (a fullscreen frame's row count changed).
// Blanking then would wipe unrelated content, so skip it; Ink repaints those lines anyway.
useLayoutEffect(() => {
return () => {
const s = inlineStampRef.current;
if (!s || isKitty(format)) return;
inlineStampRef.current = null;
const blank = `\x1b[0m${' '.repeat(s.cols)}`;
const widthShrank = (inkStdout.columns ?? 0) < (s.termCols ?? 0);
const fullscreenAnchorMoved = s.minUp === 0 && s.termRows !== inkStdout.rows;
if (widthShrank || fullscreenAnchorMoved) return;
// Never write past the right edge: a wrapped blank wipes the start of the next row.
const cols = inkStdout.columns ? Math.min(s.cols, inkStdout.columns - s.col + 1) : s.cols;
if (cols <= 0) return;
const blank = `\x1b[0m${' '.repeat(cols)}`;
let out = '';
for (let r = 0; r < s.rows; r++) out += cursorTo(s.up - r, s.col) + blank + '\x1b8\x1b7';
// Only rows inside the frame: "moving up" a negative amount is a no-op, so rows below
// the cursor would all land on the cursor's own line (e.g. the host's status bar).
// …and none above the screen's top line (a frame taller than the screen): cursor-up
// stops there, so they would all rewrite that line.
const maxUp = (inkStdout.rows ?? Infinity) - 1;
for (let r = 0; r < s.rows && s.up - r >= s.minUp; r++) {
if (s.up - r <= maxUp) out += cursorTo(s.up - r, s.col) + blank + '\x1b8\x1b7';
}
process.stdout.write(withSavedCursor(out));
};
}, [chartCols, chartRows, format]);
Expand All @@ -202,7 +231,9 @@ export function InkUPlot({
if (!inlineErasable) return;
const id = setInterval(() => {
const s = inlineStampRef.current;
if (s) process.stdout.write(withSavedCursor(cursorTo(s.up, s.col) + s.ansi));
// Not mid-resize, and not at coordinates from a different terminal size.
if (!s || resizingRef.current || s.termCols !== inkStdout.columns || s.termRows !== inkStdout.rows) return;
process.stdout.write(withSavedCursor(cursorTo(s.up, s.col) + s.ansi));
}, 120);
return () => clearInterval(id);
}, [inlineErasable]);
Expand All @@ -214,6 +245,10 @@ export function InkUPlot({

useEffect(() => {
if (canvasWidth < 8 || canvasHeight < 16) return;
// The committed size lags the requested one (a resize just settled, or width/height
// props changed): drawing now would place an image of the old size, recorded against
// the new terminal size. The committed update re-runs this effect at the right size.
if (committed.cols !== liveCols || committed.rows !== liveRows) return;
let cancelled = false;

// Serialize through renderLock — renderToImageData is not reentrant
Expand All @@ -240,14 +275,22 @@ export function InkUPlot({
});
}
if (cancelled) return;
// Out-of-band graphics are placed relative to the cursor Ink leaves after the frame
// this layout belongs to. Ink throttles frame writes and the image is often ready
// first, so wait until that frame is flushed (Ink >= 7); on older Ink, wait out one
// throttle interval (maxFps 30).
if (isRawFormat(format)) {
await (app.waitUntilRenderFlush?.() ?? new Promise<void>((r) => setTimeout(r, 50)));
if (cancelled) return;
}
// Re-check: a resize may have started while this frame was rendering.
if (resizingRef.current && isRawFormat(format)) return;

// Locate the reserved box on screen so the image lands inside it — not at the
// terminal's top-left, which overflows any layout where the chart isn't the only pane.
const geom = boxScreenGeom(boxRef.current) ?? { col: 0, row: 0, frameHeight: 0 };
const col = geom.col + 1; // 1-based terminal column of the box's left edge
const up = linesUpFromCursor(geom, inkStdout);
const { up, minUp } = cursorAnchor(geom, inkStdout);

if (isKitty(format)) {
// Kitty images live in a graphics plane, so text repaints don't erase them.
Expand All @@ -261,7 +304,10 @@ export function InkUPlot({
// Inline images (iterm2/sixels) occupy character cells. Place at the box's top-left
// and cache the stamp for the redraw interval and the blank-on-change cleanup.
process.stdout.write(withSavedCursor(cursorTo(up, col) + ansi));
inlineStampRef.current = { ansi, up, col, rows: chartRows, cols: chartCols };
inlineStampRef.current = {
ansi, up, minUp, col, rows: chartRows, cols: chartCols,
termCols: inkStdout.columns, termRows: inkStdout.rows,
};
} else {
setOutput(ansi);
}
Expand All @@ -276,7 +322,7 @@ export function InkUPlot({
});

return () => { cancelled = true; };
}, [opts, data, canvasWidth, canvasHeight, chartCols, chartRows, format, color, showAxes, resizeTick]);
}, [opts, data, canvasWidth, canvasHeight, chartCols, chartRows, format, color, showAxes, resizeTick, committed.cols, committed.rows, liveCols, liveRows]);

if (error) {
return <Text color="red">Error rendering chart: {error}</Text>;
Expand Down
135 changes: 135 additions & 0 deletions test/inline-cleanup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,138 @@ describe('inline image cleanup (iterm2)', () => {
instance.unmount();
});
});

describe('inline image cleanup after a terminal resize (iterm2)', () => {
it('does not blank at stale coordinates once the terminal size has changed', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
instance = render(<InkUPlot opts={opts} data={data} width={40} height={8} format="iterm2" />);
await until(() => writes.some(isStamp));
const before = writes.length;

// The terminal shrinks: it reflows, and Ink repaints. The old image position no longer
// means anything, so blanking it would wipe whatever now sits there (and wrap past the
// new right edge).
Object.defineProperty(instance.stdout, 'columns', { get: () => 60, configurable: true });
instance.rerender(<InkUPlot opts={opts} data={data} width={30} height={6} format="iterm2" />);
await until(() => writes.slice(before).some(isStamp));

expect(writes.slice(before).filter(isBlank)).toHaveLength(0);
instance.unmount();
});

it('never blanks past the right edge of the terminal', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
// Chart starts at column 71 of a 100-column terminal and claims 50 columns.
instance = render(
<Box paddingLeft={70}>
<InkUPlot opts={opts} data={data} width={50} height={4} format="iterm2" />
</Box>,
);
await until(() => writes.some(isStamp));

instance.rerender(<Box><Text>gone</Text></Box>);
await until(() => writes.some(isBlank));
const blank = writes.find(isBlank)!;
const widest = Math.max(...(blank.text.match(/ +/g) ?? ['']).map((s) => s.length));
expect(widest).toBeLessThanOrEqual(100 - 71 + 1);
instance.unmount();
});
});

describe('inline image cleanup stays inside the frame (iterm2)', () => {
it('never blanks rows below the cursor Ink leaves after the frame', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
// A 10-row chart clipped into a 4-row frame: the stamp's rows run past the frame. Rows
// below the cursor don't exist on screen; "moving up" a negative amount is a no-op, so
// blanking them would repeatedly overwrite the cursor's own line (e.g. a status bar).
instance = render(
<Box height={4} overflow="hidden">
<InkUPlot opts={opts} data={data} width={20} height={10} format="iterm2" />
</Box>,
);
await until(() => writes.some(isStamp));
instance.rerender(<Box height={4}><Text>gone</Text></Box>);
await until(() => writes.some(isBlank));
const blank = writes.find(isBlank)!;
const moves = [...blank.text.matchAll(/\x1b7(?:\x1b\[(\d+)A)?\x1b\[\d+G/g)].map((m) => Number(m[1] ?? 0));
// Non-fullscreen: cursor is on the line after the 4-line frame, so at most 4 rows (up 4..1).
expect(moves.length).toBeLessThanOrEqual(4);
expect(Math.min(...moves)).toBeGreaterThanOrEqual(1);
instance.unmount();
});
});

describe('inline image cleanup: when a resize still needs the blank (iterm2)', () => {
// The blank runs before Ink writes its next frame, so the stamp's cursor-relative
// coordinates stay valid unless Ink cleared (width shrink) or a fullscreen frame's anchor
// moved (row count changed). Otherwise, skipping the blank leaves old image cells behind.
const setSize = (instance: ReturnType<typeof render>, size: { columns?: number; rows?: number; isTTY?: boolean }) => {
for (const [k, v] of Object.entries(size)) {
Object.defineProperty(instance.stdout, k, { get: () => v, configurable: true });
}
};

it('still blanks after the terminal grows wider', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
instance = render(<InkUPlot opts={opts} data={data} width={40} height={8} format="iterm2" />);
await until(() => writes.some(isStamp));
const before = writes.length;
setSize(instance, { columns: 140 });
instance.rerender(<InkUPlot opts={opts} data={data} width={30} height={6} format="iterm2" />);
await until(() => writes.slice(before).some(isStamp));
expect(writes.slice(before).filter(isBlank).length).toBeGreaterThan(0);
instance.unmount();
});

it('still blanks after a height change when the frame is not fullscreen', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
instance = render(<InkUPlot opts={opts} data={data} width={40} height={8} format="iterm2" />);
await until(() => writes.some(isStamp));
const before = writes.length;
setSize(instance, { rows: 50 });
instance.rerender(<InkUPlot opts={opts} data={data} width={40} height={6} format="iterm2" />);
await until(() => writes.slice(before).some(isStamp));
expect(writes.slice(before).filter(isBlank).length).toBeGreaterThan(0);
instance.unmount();
});

it('skips the blank when a fullscreen frame\'s row count changed (its cursor anchor moved)', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
instance = render(<InkUPlot opts={opts} data={data} width={40} height={8} format="iterm2" />);
// The 8-row frame fills an 8-row TTY: fullscreen.
setSize(instance, { isTTY: true, rows: 8 });
instance.rerender(<InkUPlot opts={opts} data={data} width={40} height={8} format="iterm2" key="fs" />);
await until(() => writes.some(isStamp));
const before = writes.length;
setSize(instance, { rows: 6 });
instance.rerender(<InkUPlot opts={opts} data={data} width={40} height={6} format="iterm2" key="fs" />);
await until(() => writes.slice(before).some(isStamp));
expect(writes.slice(before).filter(isBlank)).toHaveLength(0);
instance.unmount();
});
});


describe('inline image cleanup stays on screen (iterm2)', () => {
it('never blanks rows above the screen\'s top line (frame taller than the screen)', async () => {
let instance!: ReturnType<typeof render>;
const writes = spyStdout(() => instance?.frames.length ?? 0);
// A 10-row chart in a 5-row terminal: its top rows are above the screen. Cursor-up stops
// at the top line, so blanking them would rewrite that line over and over.
instance = render(<InkUPlot opts={opts} data={data} width={20} height={10} format="iterm2" />);
Object.defineProperty(instance.stdout, 'rows', { get: () => 5, configurable: true });
await until(() => writes.some(isStamp));
instance.rerender(<Box><Text>gone</Text></Box>);
await until(() => writes.some(isBlank));
const blank = writes.find(isBlank)!;
const ups = [...blank.text.matchAll(/\x1b7(?:\x1b\[(\d+)A)?\x1b\[\d+G/g)].map((m) => Number(m[1] ?? 0));
expect(Math.max(...ups)).toBeLessThanOrEqual(5 - 1);
instance.unmount();
});
});
54 changes: 54 additions & 0 deletions test/render-flush.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import React from 'react';
import { render } from 'ink-testing-library';

// Ink throttles frame writes, and the chart image is often ready first. The image is placed
// relative to the cursor Ink leaves after the frame it was laid out for, so it must not be
// written until Ink has flushed that frame (useApp().waitUntilRenderFlush, Ink >= 7).
let releaseFlush: () => void = () => {};
const flushCalls = vi.hoisted(() => ({ count: 0 }));
vi.mock('ink', async (importOriginal) => {
const ink = await importOriginal<typeof import('ink')>();
return {
...ink,
useApp: () => ({
...ink.useApp(),
waitUntilRenderFlush: () => {
flushCalls.count++;
return new Promise<void>((resolve) => { releaseFlush = resolve; });
},
}),
};
});

const { InkUPlot } = await import('../src/index.js');

const opts = { series: [{}, { stroke: '#ffffff', width: 2 }] };
const data: [number[], number[]] = [[1, 2, 3], [10, 20, 15]];

afterEach(() => {
vi.restoreAllMocks();
});

describe('inline image placement waits for Ink to flush the frame', () => {
it('does not write the image until waitUntilRenderFlush resolves', { timeout: 20000 }, async () => {
const writes: string[] = [];
vi.spyOn(process.stdout, 'write').mockImplementation(((c: string | Uint8Array) => {
writes.push(String(c));
return true;
}) as typeof process.stdout.write);
const instance = render(<InkUPlot opts={opts} data={data} width={20} height={4} format="iterm2" />);

const start = Date.now();
while (flushCalls.count === 0 && Date.now() - start < 10000) await new Promise((r) => setTimeout(r, 20));
expect(flushCalls.count).toBeGreaterThan(0);
await new Promise((r) => setTimeout(r, 100));
expect(writes.some((w) => w.includes('\x1b]1337;File='))).toBe(false);

releaseFlush();
const t = Date.now();
while (!writes.some((w) => w.includes('\x1b]1337;File=')) && Date.now() - t < 5000) await new Promise((r) => setTimeout(r, 20));
expect(writes.some((w) => w.includes('\x1b]1337;File='))).toBe(true);
instance.unmount();
});
});
Loading