diff --git a/README.md b/README.md index c5f6896..5e3e7bd 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/InkUPlot.tsx b/src/InkUPlot.tsx index 2da89aa..03dea0f 100644 --- a/src/InkUPlot.tsx +++ b/src/InkUPlot.tsx @@ -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'; @@ -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 @@ -150,14 +155,21 @@ export function InkUPlot({ const [output, setOutput] = useState(null); const [error, setError] = useState(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 }; 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(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 @@ -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]); @@ -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]); @@ -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 @@ -240,6 +275,14 @@ 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((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; @@ -247,7 +290,7 @@ export function InkUPlot({ // 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. @@ -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); } @@ -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 Error rendering chart: {error}; diff --git a/test/inline-cleanup.test.tsx b/test/inline-cleanup.test.tsx index 9029f90..ae32def 100644 --- a/test/inline-cleanup.test.tsx +++ b/test/inline-cleanup.test.tsx @@ -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; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + 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(); + 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; + const writes = spyStdout(() => instance?.frames.length ?? 0); + // Chart starts at column 71 of a 100-column terminal and claims 50 columns. + instance = render( + + + , + ); + await until(() => writes.some(isStamp)); + + instance.rerender(gone); + 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; + 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( + + + , + ); + await until(() => writes.some(isStamp)); + instance.rerender(gone); + 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, 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; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + await until(() => writes.some(isStamp)); + const before = writes.length; + setSize(instance, { columns: 140 }); + instance.rerender(); + 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; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + await until(() => writes.some(isStamp)); + const before = writes.length; + setSize(instance, { rows: 50 }); + instance.rerender(); + 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; + const writes = spyStdout(() => instance?.frames.length ?? 0); + instance = render(); + // The 8-row frame fills an 8-row TTY: fullscreen. + setSize(instance, { isTTY: true, rows: 8 }); + instance.rerender(); + await until(() => writes.some(isStamp)); + const before = writes.length; + setSize(instance, { rows: 6 }); + instance.rerender(); + 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; + 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(); + Object.defineProperty(instance.stdout, 'rows', { get: () => 5, configurable: true }); + await until(() => writes.some(isStamp)); + instance.rerender(gone); + 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(); + }); +}); diff --git a/test/render-flush.test.tsx b/test/render-flush.test.tsx new file mode 100644 index 0000000..50fc01d --- /dev/null +++ b/test/render-flush.test.tsx @@ -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(); + return { + ...ink, + useApp: () => ({ + ...ink.useApp(), + waitUntilRenderFlush: () => { + flushCalls.count++; + return new Promise((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(); + + 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(); + }); +});