/** * KHAELOR * File: src/tui/renderer/frame.ts * Description: Pure frame composition — settle blocks to scrollback, damage-tracked live-region repaint, cursor parking. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ import { CSI, ERASE_DOWN, ERASE_LINE, HIDE_CURSOR, SHOW_CURSOR, SYNC_OFF, SYNC_ON, truncateAnsi, } from "./ansi.js"; /** One live-region snapshot: the bounded bottom strip plus the composer caret. */ export interface LiveFrame { lines: string[]; /** Row index (into `lines`) where the hardware cursor parks. */ caretRow: number; /** Column (0-based, visible columns) where the hardware cursor parks. */ caretCol: number; } export interface ComposeFrameInput { /** Live-region lines currently on screen (empty for the first paint). */ prevLines: readonly string[]; /** Caret row of the previous paint (cursor is parked there right now). */ prevCaretRow: number; /** The next live-region state. */ next: LiveFrame; /** Settled blocks to flush to scrollback above the live region, in order. */ settled: readonly string[][]; /** Terminal width in columns; every emitted line is truncated to width − 1. */ width: number; /** Wrap the paint in DEC 2026 synchronized-output markers. */ sync: boolean; } export interface ComposedFrame { /** The single write for this frame. */ data: string; /** Live-region lines now on screen — feed back as `prevLines` next frame. */ lines: string[]; caretRow: number; /** Rows repainted this frame (damage-tracking observability). */ repaintedRows: number; } /** * Compose one paint as a single ANSI string (TUI_DESIGN §1.3): * * 1. Move from the parked caret to the live-region origin. * 2. If settled blocks are pending: erase the live region, print the settled * lines as plain scrollback writes (immutable — never repainted again). * 3. Repaint only the live rows that changed (per-row damage tracking); erase * below when the region shrank. * 4. Park the hardware cursor at the composer caret. * * Wrapped in SYNC_ON/SYNC_OFF when supported, cursor hide/show bracketing * otherwise. Pure: no I/O, fully deterministic — the terminal writer is a * thin sink around this. */ export function composeFrame(input: ComposeFrameInput): ComposedFrame { const { prevLines, prevCaretRow, next, settled, width, sync } = input; const lineWidth = Math.max(1, width - 1); const lines = next.lines.map((l) => truncateAnsi(l, lineWidth)); const caretRow = Math.min(Math.max(0, next.caretRow), Math.max(0, lines.length - 1)); let b = sync ? SYNC_ON + HIDE_CURSOR : HIDE_CURSOR; // Move to the live-region origin, column 1. b += prevCaretRow > 0 ? `${CSI}${prevCaretRow}F` : "\r"; let effectivePrev: readonly string[] = prevLines; if (settled.length > 0) { // Erase the live region, then print settled lines; they scroll away and // are never touched again. b += ERASE_DOWN; for (const block of settled) { for (const line of block) b += truncateAnsi(line, lineWidth) + "\r\n"; } effectivePrev = []; } const full = effectivePrev.length !== lines.length; const shrunk = effectivePrev.length > lines.length; let repaintedRows = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i] as string; if (full || line !== effectivePrev[i]) { b += ERASE_LINE + line; repaintedRows += 1; } if (i < lines.length - 1) b += "\r\n"; } if (shrunk) b += ERASE_DOWN; // Park the cursor at the composer caret — unconditionally, every frame. const up = lines.length - 1 - caretRow; if (up > 0) b += `${CSI}${up}A`; b += `${CSI}${Math.min(next.caretCol, lineWidth) + 1}G`; b += sync ? SHOW_CURSOR + SYNC_OFF : SHOW_CURSOR; return { data: b, lines, caretRow, repaintedRows }; }