/** * Kitty Unicode-placeholder rendering. * * Why not the classic `a=T` placement pi uses by default: that draws the image * at the *cursor*, so the image is not part of the text grid. Any redraw that * moves the cursor must retransmit or delete/replace the image, and pi's TUI * bails to a full clear-and-redraw whenever a reserved image block might scroll * (see pi-tui tui.js: "kitty image pre-clear would scroll" -> fullRender(true)). * That full redraw is the flicker. * * Virtual placements (`U=1`) invert this: the image is transmitted ONCE with an * id, then displayed by printing ordinary text cells (U+10EEEE + diacritics) * whose foreground colour carries the image id. Those cells scroll, get diffed, * and get overwritten exactly like text, so the normal differential redraw path * handles them and nothing is ever retransmitted. */ import { MAX_CELLS, PLACEHOLDER, ROWCOLUMN_DIACRITICS } from "./diacritics.ts"; export interface CellSize { widthPx: number; heightPx: number; } export interface GridSize { cols: number; rows: number; } const CHUNK_SIZE = 4096; /** * Fit an image into a cell grid, preserving aspect ratio. * * Rounds down, not up: an over-tall estimate makes the TUI reserve rows the * image does not fill, which shows up as a gap and, worse, can push the block * past the viewport and trigger the full-redraw path we are avoiding. */ export function fitToGrid( imageWidthPx: number, imageHeightPx: number, cell: CellSize, maxCols: number, maxRows: number, ): GridSize { const w = Math.max(1, imageWidthPx); const h = Math.max(1, imageHeightPx); const cw = Math.max(1, cell.widthPx); const ch = Math.max(1, cell.heightPx); const colLimit = Math.max(1, Math.min(maxCols, MAX_CELLS)); const rowLimit = Math.max(1, Math.min(maxRows, MAX_CELLS)); // Scale so the image fits both limits. const scale = Math.min((colLimit * cw) / w, (rowLimit * ch) / h, 1); const cols = Math.max(1, Math.min(colLimit, Math.round((w * scale) / cw))); const rows = Math.max(1, Math.min(rowLimit, Math.round((h * scale) / ch))); return { cols, rows }; } /** * Transmit an image and create a virtual placement for it. * * `q=2` suppresses both the OK and the error reply. A reply would land in * stdin, and pi's input parser would render it as junk typed into the editor. */ export function transmitVirtual( base64Data: string, imageId: number, grid: GridSize, ): string { const params = [ "a=T", // transmit and display "f=100", // PNG payload "U=1", // virtual placement: do not draw, wait for placeholders "q=2", // no response, ever `i=${imageId}`, `c=${grid.cols}`, `r=${grid.rows}`, ]; if (base64Data.length <= CHUNK_SIZE) { return `\x1b_G${params.join(",")};${base64Data}\x1b\\`; } // Chunked transmit: only the first chunk carries the control keys. const out: string[] = []; for (let offset = 0; offset < base64Data.length; offset += CHUNK_SIZE) { const chunk = base64Data.slice(offset, offset + CHUNK_SIZE); const more = offset + CHUNK_SIZE < base64Data.length ? 1 : 0; out.push( offset === 0 ? `\x1b_G${params.join(",")},m=1;${chunk}\x1b\\` : `\x1b_Gm=${more};${chunk}\x1b\\`, ); } return out.join(""); } function diacritic(n: number): string { const cp = ROWCOLUMN_DIACRITICS[n]; if (cp === undefined) throw new RangeError(`no diacritic for index ${n}`); return String.fromCodePoint(cp); } /** * Build the placeholder text rows that display a transmitted image. * * Every cell is fully qualified (row + column + high-byte diacritics) rather * than relying on the spec's inheritance rules. Inheritance breaks the moment * anything is drawn between two cells, and a TUI reflow can do exactly that. * Explicit cells cost bytes and buy correctness. */ export function placeholderRows(imageId: number, grid: GridSize): string[] { if (grid.cols > MAX_CELLS || grid.rows > MAX_CELLS) { throw new RangeError(`image grid ${grid.cols}x${grid.rows} exceeds ${MAX_CELLS} cells`); } // Low 24 bits go in the foreground colour, the 4th byte in a third diacritic. const rgb = imageId & 0xffffff; const high = (imageId >> 24) & 0xff; const fg = `\x1b[38;2;${(rgb >> 16) & 0xff};${(rgb >> 8) & 0xff};${rgb & 0xff}m`; const highMark = high > 0 ? diacritic(high) : ""; const rows: string[] = []; for (let r = 0; r < grid.rows; r++) { let line = fg; for (let c = 0; c < grid.cols; c++) { line += PLACEHOLDER + diacritic(r) + diacritic(c) + highMark; } rows.push(`${line}\x1b[39m`); } return rows; } /** Free a transmitted image and all of its virtual placements. */ export function deleteVirtual(imageId: number): string { return `\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`; } /** * Image ids are a global namespace shared with pi itself and any other * extension. Random 32-bit ids keep the collision odds negligible without * needing coordination. Zero is reserved by the protocol. */ export function allocateImageId(): number { return Math.floor(Math.random() * 0xfffffffe) + 1; }