/** * A pi-tui Component that renders an image as Unicode placeholder text. * * The transmit escape and the visible cells are deliberately separated: * * transmit (ESC _G ... U=1) -> written straight to the tty, once * placeholder cells -> returned from render(), pure text * * render() must never return the transmit sequence, because pi-tui's * isImageLine() looks for the ESC_G prefix and switches that line onto the * reserved-rows path that can bail out to a full-screen redraw. Placeholder * cells contain no escape besides an SGR colour, so pi-tui treats them as * ordinary text and diffs them normally. That is the entire anti-flicker trick. * * Writing the transmit out of band is safe specifically because U=1 creates a * *virtual* placement: it draws nothing and does not move the cursor, so it * cannot corrupt whatever the TUI is painting when it lands. */ import type { Component } from "@earendil-works/pi-tui"; import type { Dimensions } from "./dimensions.ts"; import { allocateImageId, deleteVirtual, fitToGrid, placeholderRows, transmitVirtual, type CellSize, type GridSize, } from "./placeholder.ts"; export interface WlImageOptions { cell: CellSize; maxWidthCells: number; maxHeightCells: number; /** Where the transmit escape goes. Defaults to the real tty. */ write?: (data: string) => void; /** Shown instead of the image when rendering is impossible. */ fallbackText?: string; fallbackStyle?: (text: string) => string; } export class WlImage implements Component { private readonly imageId = allocateImageId(); private grid: GridSize | undefined; private transmitted = false; private cachedRows: string[] | undefined; private cachedWidth: number | undefined; private failed = false; // Written out longhand rather than as constructor parameter properties: pi // loads extensions with Node's strip-only TypeScript mode, which rejects // them ("TypeScript parameter property is not supported in strip-only mode"). private readonly base64Data: string; private readonly dimensions: Dimensions; private readonly options: WlImageOptions; constructor(base64Data: string, dimensions: Dimensions, options: WlImageOptions) { this.base64Data = base64Data; this.dimensions = dimensions; this.options = options; } getImageId(): number { return this.imageId; } /** Rows this image will occupy, so callers can lay out around it. */ getGrid(width: number): GridSize { return this.computeGrid(width); } private write(data: string): void { const sink = this.options.write ?? ((chunk: string) => process.stdout.write(chunk)); sink(data); } private computeGrid(width: number): GridSize { // Leave a column of slack: a placeholder row that exactly equals the // viewport width can wrap on some terminals, which shifts every // following row and forces a repaint. const maxCols = Math.max(1, Math.min(width - 1, this.options.maxWidthCells)); return fitToGrid( this.dimensions.widthPx, this.dimensions.heightPx, this.options.cell, maxCols, this.options.maxHeightCells, ); } private fallback(): string[] { const text = this.options.fallbackText ?? "[image]"; return [this.options.fallbackStyle ? this.options.fallbackStyle(text) : text]; } render(width: number): string[] { if (this.failed) return this.fallback(); if (this.cachedRows && this.cachedWidth === width) return this.cachedRows; const grid = this.computeGrid(width); try { // Retransmit only when the geometry actually changed. The image data // is re-sent on resize because the virtual placement's c/r are baked // in at transmit time; on a static chat log this happens ~never. const geometryChanged = !this.grid || this.grid.cols !== grid.cols || this.grid.rows !== grid.rows; if (!this.transmitted || geometryChanged) { if (this.transmitted) this.write(deleteVirtual(this.imageId)); this.write(transmitVirtual(this.base64Data, this.imageId, grid)); this.transmitted = true; } this.grid = grid; this.cachedRows = placeholderRows(this.imageId, grid); this.cachedWidth = width; return this.cachedRows; } catch { // Oversized grid or a bad payload: show text rather than garbage. this.failed = true; return this.fallback(); } } invalidate(): void { this.cachedRows = undefined; this.cachedWidth = undefined; } /** Free the image in the terminal. Safe to call more than once. */ dispose(): void { if (!this.transmitted) return; this.write(deleteVirtual(this.imageId)); this.transmitted = false; } }