/** * Frame buffer: a 2D grid of terminal cells with differential rendering. * * Two buffers (current + previous) are maintained. After rendering nodes * into the current buffer, diffFrames() produces the minimal set of * changed runs. The ANSI serializer converts those runs into escape * sequences, tracking SGR state to minimize output bytes. */ // --- Cell representation --- // Attribute bitfield export const Attr = { None: 0, Bold: 1, Dim: 2, Italic: 4, Underline: 8, Inverse: 16, Strikethrough: 32, } as const; export type Attr = number; // Default/no color sentinel — means "use terminal default" export const NO_COLOR = -1; export interface Cell { /** Attribute bitfield (bold, italic, etc.) */ attrs: Attr; /** Background color: 24-bit RGB packed as number, or NO_COLOR */ bg: number; /** Single character or grapheme cluster */ char: string; /** Foreground color: 24-bit RGB packed as number, or NO_COLOR */ fg: number; /** OSC 8 hyperlink URL, or empty string for no link */ link: string; /** Display width: 1 normal, 2 wide (CJK/emoji), 0 continuation cell */ width: number; } // Canonical blank cell. Every cleared slot in every buffer points at this one // frozen object, so two blank slots — even across the prev/next buffers — are // pointer-equal, which `cellsEqual` short-circuits on. It is frozen because it // must never be mutated, and nothing ever mutates it: in-place writers either // materialize a blank into its own scratch first (getMutable) or skip blanks // outright (applyColorField, via `char === " "`), and every slot the buffer // means to write gets a scratch cell, never this one. const SHARED_BLANK: Cell = Object.freeze({ char: " ", fg: NO_COLOR, bg: NO_COLOR, attrs: Attr.None, link: "", width: 1, }); // Per-glyph width memo. Bun.stringWidth is a native call; a terminal frame is // dominated by a tiny set of repeated glyphs (ASCII, box-drawing borders, block // elements, braille spinners), so caching width by code point collapses // hundreds of native calls per frame into a handful of Map lookups. Bounded by // the number of distinct glyphs an app ever paints (small). ASCII is handled // inline without touching the map. const charWidthCache = new Map(); /** Display width of a single code-point string, memoized. */ export function charWidth(char: string): number { const code = char.codePointAt(0) ?? 0; if (code >= 0x20 && code < 0x7f) return 1; // printable ASCII — always 1 let w = charWidthCache.get(code); if (w === undefined) { w = Bun.stringWidth(char); charWidthCache.set(code, w); } return w; } export function createCell( char = " ", fg = NO_COLOR, bg = NO_COLOR, attrs: Attr = Attr.None, width = 1, link = "", ): Cell { return { char, fg, bg, attrs, link, width }; } function cellsEqual(a: Cell, b: Cell): boolean { // Identity short-circuit: cleared cells in both buffers share the single // SHARED_BLANK reference, so blank↔blank regions (the common case in a sparse // diff) compare in one pointer check instead of six field comparisons. if (a === b) return true; return ( a.char === b.char && a.fg === b.fg && a.bg === b.bg && a.attrs === b.attrs && a.link === b.link && a.width === b.width ); } // --- Clip region --- /** Rectangular clip region (exclusive right/bottom). */ export interface ClipRect { bottom: number; left: number; right: number; top: number; } // --- Frame buffer --- export class FrameBuffer { readonly cols: number; readonly rows: number; /** The visible grid: each slot points at either SHARED_BLANK or its scratch. */ readonly cells: Cell[]; /** * One persistent, mutable Cell per slot, allocated once. Writing a slot * mutates its scratch cell in place and points `cells[i]` at it — so a frame * allocates zero new cells no matter how much it paints. Blank slots point at * the shared frozen blank instead, keeping the diff's identity fast path. */ private readonly scratch: Cell[]; /** Rows touched by set()/writeString()/fillRect() since last clear(). */ readonly dirtyRows: Set = new Set(); constructor(cols: number, rows: number) { this.cols = cols; this.rows = rows; const n = cols * rows; this.cells = new Array(n); this.scratch = new Array(n); for (let i = 0; i < n; i++) { this.cells[i] = SHARED_BLANK; this.scratch[i] = createCell(); } this.dirtyRows.clear(); } /** Mutate the scratch cell for a slot in place and make it the visible cell. */ private writeCell( idx: number, char: string, fg: number, bg: number, attrs: Attr, width: number, link: string, ) { const cell = this.scratch[idx] as Cell; cell.char = char; cell.fg = fg; cell.bg = bg; cell.attrs = attrs; cell.width = width; cell.link = link; this.cells[idx] = cell; } clear() { for (let i = 0; i < this.cells.length; i++) { this.cells[i] = SHARED_BLANK; } this.dirtyRows.clear(); } /** Reset only rows that were previously painted, then clear the dirty set. */ clearDirtyRows() { for (const row of this.dirtyRows) { const start = row * this.cols; const end = start + this.cols; for (let i = start; i < end; i++) { this.cells[i] = SHARED_BLANK; } } this.dirtyRows.clear(); } /** Find the last row that has non-empty content. Returns 0 if all empty. */ contentHeight(): number { for (let row = this.rows - 1; row >= 0; row--) { for (let col = 0; col < this.cols; col++) { const cell = this.cells[row * this.cols + col] as Cell; if ( cell.char !== " " || cell.fg !== NO_COLOR || cell.bg !== NO_COLOR || cell.attrs !== Attr.None ) { return row + 1; } } } return 0; } get(col: number, row: number): Cell { return this.cells[row * this.cols + col] as Cell; } /** * Return a writable cell for a slot, for callers that mutate it in place * (e.g. the selection-inverse overlay). A blank slot is first materialized * into its own scratch cell — never hand out the frozen shared blank — and * the row is marked dirty. Callers must pass in-bounds coordinates. */ getMutable(col: number, row: number): Cell { const idx = row * this.cols + col; if ((this.cells[idx] as Cell) === SHARED_BLANK) { this.writeCell(idx, " ", NO_COLOR, NO_COLOR, Attr.None, 1, ""); this.dirtyRows.add(row); } return this.cells[idx] as Cell; } set(col: number, row: number, cell: Cell) { if (col < 0 || col >= this.cols || row < 0 || row >= this.rows) return; // Copy the source cell's fields into this slot's scratch rather than // aliasing the reference — callers (e.g. cross-buffer scroll copies) may // hand us a cell owned by another buffer, and the two pools must stay // independent. A blank source is forwarded as the shared blank. const idx = row * this.cols + col; if (cell === SHARED_BLANK) { this.cells[idx] = SHARED_BLANK; } else { this.writeCell( idx, cell.char, cell.fg, cell.bg, cell.attrs, cell.width, cell.link, ); } this.dirtyRows.add(row); } /** Write a string at (col, row) with given attributes. Returns columns consumed. */ writeString( col: number, row: number, str: string, fg = NO_COLOR, bg = NO_COLOR, attrs: Attr = Attr.None, clip?: ClipRect, link = "", ): number { const clipRight = clip ? clip.right : this.cols; const clipBottom = clip ? clip.bottom : this.rows; const clipLeft = clip ? clip.left : 0; const clipTop = clip ? clip.top : 0; if (row < clipTop || row >= clipBottom) return 0; // writeString only ever touches this one row, so compute its base offset // once and mark it dirty a single time after the loop (if anything landed). const rowStart = row * this.cols; let wrote = false; let c = col; for (const char of str) { if (c >= clipRight) break; // Memoized per-glyph width — Bun.stringWidth is a native call and the // single hottest cost in paint. charWidth() fast-paths ASCII and // caches everything else (borders, blocks, CJK, emoji) by code point. const w = charWidth(char); if (w === 0) continue; // Skip cells left of clip, but still advance column if (c + w <= clipLeft) { c += w; continue; } // Skip wide chars that straddle the right clip edge if (w === 2 && c + 1 >= clipRight) { c += w; continue; } // c < clipRight ≤ cols holds from the loop guard; c may still be < 0 // when a wide glyph straddles the left edge, so guard the low bound. if (c >= 0) { this.writeCell(rowStart + c, char, fg, bg, attrs, w, link); wrote = true; } if (w === 2 && c + 1 >= 0 && c + 1 < this.cols) { this.writeCell(rowStart + c + 1, "", fg, bg, attrs, 0, link); wrote = true; } c += w; } if (wrote) this.dirtyRows.add(row); return c - col; } /** Fill a rectangular region with a cell value. */ fillRect( col: number, row: number, width: number, height: number, char = " ", fg = NO_COLOR, bg = NO_COLOR, attrs: Attr = Attr.None, clip?: ClipRect, ) { const rMin = Math.max(row, clip ? clip.top : 0); const rMax = Math.min(row + height, clip ? clip.bottom : this.rows); const cMin = Math.max(col, clip ? clip.left : 0); const cMax = Math.min(col + width, clip ? clip.right : this.cols); // rMin/rMax/cMin/cMax are already clamped to valid bounds above. The column // span is the same for every row, so test it once rather than per row. if (cMax <= cMin) return; for (let r = rMin; r < rMax; r++) { const rowStart = r * this.cols; for (let c = cMin; c < cMax; c++) { this.writeCell(rowStart + c, char, fg, bg, attrs, 1, ""); } this.dirtyRows.add(r); } } } // --- Diff engine --- export interface ChangedRun { /** Reference to the source cell array (no copy). */ cells: Cell[]; /** End index (exclusive) into cells. */ end: number; row: number; /** Start index into cells. */ start: number; startCol: number; } /** * Compare two frame buffers and produce the minimal set of changed runs. * Optionally restrict to a set of dirty rows for faster diffing. */ export function diffFrames( prev: FrameBuffer, curr: FrameBuffer, dirtyRows?: Set, ): ChangedRun[] { const changes: ChangedRun[] = []; const { cols, rows, cells } = curr; for (let row = 0; row < rows; row++) { if (dirtyRows && !dirtyRows.has(row)) continue; const rowStart = row * cols; let runStart = -1; for (let col = 0; col < cols; col++) { const idx = rowStart + col; const prevCell = prev.cells[idx] as Cell; const currCell = cells[idx] as Cell; if (cellsEqual(prevCell, currCell)) { if (runStart >= 0) { changes.push({ row, startCol: runStart, cells, start: rowStart + runStart, end: idx, }); runStart = -1; } } else { if (runStart < 0) runStart = col; } } if (runStart >= 0) { changes.push({ row, startCol: runStart, cells, start: rowStart + runStart, end: rowStart + cols, }); } } return changes; } // --- SGR state tracker + ANSI serializer --- interface SGRState { attrs: Attr; bg: number; fg: number; } function sgrReset(): SGRState { return { fg: NO_COLOR, bg: NO_COLOR, attrs: Attr.None }; } /** * Build the minimal SGR escape sequence to transition `from` → the target * (`toFg`, `toBg`, `toAttrs`). Returns empty string if no change is needed. * Takes the target as primitives so the caller can carry SGR state in a single * reused object instead of allocating one per cell. */ function buildSGRDelta( from: SGRState, toFg: number, toBg: number, toAttrs: Attr, ): string { if (from.fg === toFg && from.bg === toBg && from.attrs === toAttrs) { return ""; } const parts: string[] = []; // Selectively disable removed attributes instead of full reset. // SGR 22 disables both Bold and Dim, so if one is removed but the other // kept, we disable with 22 then re-enable the surviving one. const attrsRemoved = from.attrs & ~toAttrs; if (attrsRemoved) { if (attrsRemoved & (Attr.Bold | Attr.Dim)) { parts.push("22"); // disables both bold and dim // Re-enable the survivor if only one was removed if (toAttrs & Attr.Bold) parts.push("1"); if (toAttrs & Attr.Dim) parts.push("2"); } if (attrsRemoved & Attr.Italic) parts.push("23"); if (attrsRemoved & Attr.Underline) parts.push("24"); if (attrsRemoved & Attr.Inverse) parts.push("27"); if (attrsRemoved & Attr.Strikethrough) parts.push("29"); } // Attributes to add (that weren't already re-enabled above) const attrsAdded = toAttrs & ~from.attrs; // Skip bold/dim if they were already handled by the removal path above const boldDimHandled = attrsRemoved & (Attr.Bold | Attr.Dim); if (attrsAdded & Attr.Bold && !boldDimHandled) parts.push("1"); if (attrsAdded & Attr.Dim && !boldDimHandled) parts.push("2"); if (attrsAdded & Attr.Italic) parts.push("3"); if (attrsAdded & Attr.Underline) parts.push("4"); if (attrsAdded & Attr.Inverse) parts.push("7"); if (attrsAdded & Attr.Strikethrough) parts.push("9"); // Foreground — no longer reset by attribute changes, only emitted when changed if (from.fg !== toFg) { if (toFg === NO_COLOR) { parts.push("39"); // default fg } else { const r = (toFg >> 16) & 0xff; const g = (toFg >> 8) & 0xff; const b = toFg & 0xff; parts.push(`38;2;${r};${g};${b}`); } } // Background if (from.bg !== toBg) { if (toBg === NO_COLOR) { parts.push("49"); // default bg } else { const r = (toBg >> 16) & 0xff; const g = (toBg >> 8) & 0xff; const b = toBg & 0xff; parts.push(`48;2;${r};${g};${b}`); } } if (parts.length === 0) return ""; return `\x1b[${parts.join(";")}m`; } /** * Serialize changed runs into a minimal ANSI byte sequence. * Tracks SGR state across runs to avoid redundant attribute codes. */ export function serializeChanges( changes: ChangedRun[], initialState?: SGRState, enableLinks = true, ): string { if (changes.length === 0) return ""; // Local, mutable SGR state carried across all runs/cells (copied so a // caller-provided initialState is never mutated). const state: SGRState = initialState ? { fg: initialState.fg, bg: initialState.bg, attrs: initialState.attrs } : sgrReset(); let currentLink = ""; const parts: string[] = []; for (const run of changes) { // CUP: move cursor to position (1-indexed) parts.push(`\x1b[${run.row + 1};${run.startCol + 1}H`); for (let i = run.start; i < run.end; i++) { const cell = run.cells[i] as Cell; // Skip continuation cells (part of a wide character) if (cell.width === 0) continue; const sgr = buildSGRDelta(state, cell.fg, cell.bg, cell.attrs); if (sgr) parts.push(sgr); // Carry SGR state in one reused object — no per-cell allocation. state.fg = cell.fg; state.bg = cell.bg; state.attrs = cell.attrs; // OSC 8 hyperlink transitions (only when terminal supports it) if (enableLinks && cell.link !== currentLink) { if (currentLink) { parts.push("\x1b]8;;\x1b\\"); // close previous link } if (cell.link) { parts.push(`\x1b]8;;${cell.link}\x1b\\`); // open new link } currentLink = cell.link; } parts.push(cell.char); } } // Close any open link if (enableLinks && currentLink) { parts.push("\x1b]8;;\x1b\\"); } // Reset SGR state so terminal attributes don't bleed if ( state.attrs !== Attr.None || state.fg !== NO_COLOR || state.bg !== NO_COLOR ) { parts.push("\x1b[0m"); } return parts.join(""); } /** * Wrap frame data in synchronized output markers (DEC private mode 2026). * The terminal holds all rendering until ESU, then paints atomically. */ export function wrapSynchronized(data: string): string { return `\x1b[?2026h${data}\x1b[?2026l`; } // --- Color helpers --- /** * Resolve any ColorInput to a packed 24-bit RGB number for Cell fg/bg. * Accepts hex (#rgb, #rrggbb), named colors, rgb(), hsl(), numbers, etc. * Returns NO_COLOR for invalid input. */ export function color(input: Bun.ColorInput): number { return Bun.color(input, "number") ?? NO_COLOR; }