import { logForDebugging } from './_internal/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; prevFrameContaminated: boolean; }; export type Renderer = (options: RenderOptions) => Frame; export default function createRenderer( node: DOMElement, stylePool: StylePool, ): Renderer { // The Output instance lives across frames so its char cache (tokenize + // grapheme clustering) accumulates — the same lines tend to come back // unchanged frame after frame, and the cache turns those into pointer // comparisons. let output: Output | undefined; return options => { const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } = options; const prevScreen = frontFrame.screen; const backScreen = backFrame.screen; // Pools are read off the back buffer rather than captured in the // closure because a generational reset may have swapped them out // between frames. const charPool = backScreen.charPool; const hyperlinkPool = backScreen.hyperlinkPool; // Bail out cleanly when the yoga node is missing or hasn't laid out. // getComputedHeight() returns NaN before calculateLayout() runs, and // we also defend against the negative/Infinity cases that would // throw RangeError when allocating cell 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 when there *is* a yoga node but its dimensions are nonsense — // that case shouldn't occur and the diagnostic helps trace it. 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 invariant: the screen buffer IS the alt buffer and must // be exactly terminalRows tall. The expected shape is a top-level // , in which case yogaHeight equals // terminalRows naturally. When a sibling escapes that box (a layout // bug we've actually shipped — a stray component rendered next to the // fullscreen layout instead of inside it), yogaHeight exceeds // terminalRows and several downstream invariants collapse: the // viewport +1 trick, the cursor.y clamp, log-update's heightDelta===0 // fast path. Clamping here makes the offending sibling invisible // (overflow cells land past screen.height and setCellAt drops them) // instead of corrupting the whole terminal — and we log so the // sibling is easy to find. 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(); // Decide whether the blit fast path can use prevScreen. Two // independent contamination sources to consider: // - prevFrameContaminated (passed in): selection overlay, alt-screen // reset, or forceRedraw mutated the screen after we returned it. // - absoluteRemoved (consumed here): an absolute-positioned node // was removed since last frame. Its painted cells may have // covered non-siblings (e.g. an overlay over an unrelated // ScrollBox earlier in tree order), so blitting from prevScreen // would restore those pixels. hasRemovedChild only protects // direct siblings; absolute removals reach across subtrees. // Normal-flow removals don't paint outside their subtree and are safe. const absoluteRemoved = consumeAbsoluteRemovedFlag(node); renderNodeToOutput(node, output, { prevScreen: absoluteRemoved || options.prevFrameContaminated ? undefined : prevScreen, }); const renderedScreen = output.get(); // Drain continuation: when a ScrollBox is in the middle of a multi- // frame scroll drain, the render that just finished cleared its // dirty flag, which would make the root blit skip the subtree next // frame. Re-mark it so the next frame walks back in. This has to run // AFTER renderNodeToOutput; otherwise the end-of-render dirty clear // would overwrite the mark. const drainNode = getScrollDrainNode(); if (drainNode) markDirty(drainNode); return { scrollHint: options.altScreen ? getScrollHint() : null, scrollDrainPending: drainNode !== null, screen: renderedScreen, viewport: { width: terminalWidth, // Alt-screen viewport height is reported as `rows + 1` so the // diff layer's `screen.height >= viewport.height` predicate // (which would otherwise treat exactly-filling content as // overflowing into scrollback) never fires. Alt-screen content // is always exactly `rows` tall via the wrapping , and the // cursor never actually moves because we clamp it below — the // fake +1 just keeps log-update from issuing a full-reset. height: options.altScreen ? terminalRows + 1 : terminalRows, }, cursor: { x: 0, // Keep the cursor inside the visible viewport in alt-screen mode. // If we let cursor.y = screen.height when screen.height === // terminalRows exactly (the steady state when content fills the // alt buffer), log-update's cursor-restore emits an LF on the // last row, scrolls one row off the top of the alt buffer, and // desyncs our diff model. The cursor itself is hidden in this // mode so the y value only matters for the diff coords. y: options.altScreen ? Math.max(0, Math.min(screen.height, terminalRows) - 1) : screen.height, // Hide the cursor while there's dynamic content to repaint; // leaving it visible would smear it across the diff path. In // non-TTY mode we keep it visible since nothing animates anyway. visible: !isTTY || screen.height === 0, }, }; }; }