import { logForDebugging } from 'src/utils/debug.js' import { type DOMElement, markDirty } from './dom.js' import type { Frame } from './frame.js' import { consumeAbsoluteRemovedFlag } from './node-cache.js' import Output from './output.js' import renderNodeToOutput, { getScrollDrainNode, getScrollHint, resetLayoutShifted, resetScrollDrainNode, resetScrollHint, } from './render-node-to-output.js' import { createScreen, type StylePool } from './screen.js' export type RenderOptions = { frontFrame: Frame backFrame: Frame isTTY: boolean terminalWidth: number terminalRows: number altScreen: boolean // True when the previous frame's screen buffer was mutated post-render // (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT), // or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would // copy stale inverted cells, blanks, or nothing. When false, blit is safe. prevFrameContaminated: boolean } export type Renderer = (options: RenderOptions) => Frame export default function createRenderer( node: DOMElement, stylePool: StylePool, ): Renderer { // Reuse Output across frames so charCache (tokenize + grapheme clustering) // persists — most lines don't change between renders. let output: Output | undefined return options => { const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } = options const prevScreen = frontFrame.screen const backScreen = backFrame.screen // Read pools from the back buffer's screen — pools may be replaced // between frames (generational reset), so we can't capture them in the closure const charPool = backScreen.charPool const hyperlinkPool = backScreen.hyperlinkPool // Return empty frame if yoga node doesn't exist or layout hasn't been computed yet. // getComputedHeight() returns NaN before calculateLayout() is called. // Also check for invalid dimensions (negative, Infinity) that would cause RangeError // when creating arrays. const computedHeight = node.yogaNode?.getComputedHeight() const computedWidth = node.yogaNode?.getComputedWidth() const hasInvalidHeight = computedHeight === undefined || !Number.isFinite(computedHeight) || computedHeight < 0 const hasInvalidWidth = computedWidth === undefined || !Number.isFinite(computedWidth) || computedWidth < 0 if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) { // Log to help diagnose root cause (visible with --debug flag) if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) { logForDebugging( `Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` + `childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`, ) } return { screen: createScreen( terminalWidth, 0, stylePool, charPool, hyperlinkPool, ), viewport: { width: terminalWidth, height: terminalRows }, cursor: { x: 0, y: 0, visible: true }, } } const width = Math.floor(node.yogaNode.getComputedWidth()) const yogaHeight = Math.floor(node.yogaNode.getComputedHeight()) // Alt-screen: the screen buffer IS the alt buffer — always exactly // terminalRows tall. wraps children in , so yogaHeight should equal // terminalRows. But if something renders as a SIBLING of that Box // (bug: MessageSelector was outside ), yogaHeight // exceeds rows and every assumption below (viewport +1 hack, cursor.y // clamp, log-update's heightDelta===0 fast path) breaks, desyncing // virtual/physical cursors. Clamping here enforces the invariant: // overflow writes land at y >= screen.height and setCellAt drops // them. The sibling is invisible (obvious, easy to find) instead of // corrupting the whole terminal. const height = options.altScreen ? terminalRows : yogaHeight if (options.altScreen && yogaHeight > terminalRows) { logForDebugging( `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` + `something is rendering outside . Overflow clipped.`, { level: 'warn' }, ) } const screen = backScreen ?? createScreen(width, height, stylePool, charPool, hyperlinkPool) if (output) { output.reset(width, height, screen) } else { output = new Output({ width, height, stylePool, screen }) } resetLayoutShifted() resetScrollHint() resetScrollDrainNode() // prevFrameContaminated: selection overlay mutated the returned screen // buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it // with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame // would copy stale inverted cells / blanks / nothing. When clean, blit // restores the O(unchanged) fast path for steady-state frames (spinner // tick, text stream). // Removing an absolute-positioned node poisons prevScreen: it may // have painted over non-siblings (e.g. an overlay over a ScrollBox // earlier in tree order), so their blits would restore the removed // node's pixels. hasRemovedChild only shields direct siblings. // Normal-flow removals don't paint cross-subtree and are fine. const absoluteRemoved = consumeAbsoluteRemovedFlag() renderNodeToOutput(node, output, { prevScreen: absoluteRemoved || options.prevFrameContaminated ? undefined : prevScreen, }) const renderedScreen = output.get() // Drain continuation: render cleared scrollbox.dirty, so next frame's // root blit would skip the subtree. markDirty walks ancestors so the // next frame descends. Done AFTER render so the clear-dirty at the end // of renderNodeToOutput doesn't overwrite this. const drainNode = getScrollDrainNode() if (drainNode) markDirty(drainNode) return { scrollHint: options.altScreen ? getScrollHint() : null, scrollDrainPending: drainNode !== null, screen: renderedScreen, viewport: { width: terminalWidth, // Alt screen: fake viewport.height = rows + 1 so that // shouldClearScreen()'s `screen.height >= viewport.height` check // (which treats exactly-filling content as "overflows" for // scrollback purposes) never fires. Alt-screen content is always // exactly `rows` tall (via ) but never // scrolls — the cursor.y clamp below keeps the cursor-restore // from emitting an LF. With the standard diff path, every frame // is incremental; no fullResetSequence_CAUSES_FLICKER. height: options.altScreen ? terminalRows + 1 : terminalRows, }, cursor: { x: 0, // In the alt screen, keep the cursor inside the viewport. When // screen.height === terminalRows exactly (content fills the alt // screen), cursor.y = screen.height would trigger log-update's // cursor-restore LF at the last row, scrolling one row off the top // of the alt buffer and desyncing the diff's cursor model. The // cursor is hidden so its position only matters for diff coords. y: options.altScreen ? Math.max(0, Math.min(screen.height, terminalRows) - 1) : screen.height, // Hide cursor when there's dynamic output to render (only in TTY mode) visible: !isTTY || screen.height === 0, }, } } }