/ ink / renderer.ts
renderer.ts
  1  import { logForDebugging } from 'src/utils/debug.js'
  2  import { type DOMElement, markDirty } from './dom.js'
  3  import type { Frame } from './frame.js'
  4  import { consumeAbsoluteRemovedFlag } from './node-cache.js'
  5  import Output from './output.js'
  6  import renderNodeToOutput, {
  7    getScrollDrainNode,
  8    getScrollHint,
  9    resetLayoutShifted,
 10    resetScrollDrainNode,
 11    resetScrollHint,
 12  } from './render-node-to-output.js'
 13  import { createScreen, type StylePool } from './screen.js'
 14  
 15  export type RenderOptions = {
 16    frontFrame: Frame
 17    backFrame: Frame
 18    isTTY: boolean
 19    terminalWidth: number
 20    terminalRows: number
 21    altScreen: boolean
 22    // True when the previous frame's screen buffer was mutated post-render
 23    // (selection overlay), reset to blank (alt-screen enter/resize/SIGCONT),
 24    // or reset to 0×0 (forceRedraw). Blitting from such a prevScreen would
 25    // copy stale inverted cells, blanks, or nothing. When false, blit is safe.
 26    prevFrameContaminated: boolean
 27  }
 28  
 29  export type Renderer = (options: RenderOptions) => Frame
 30  
 31  export default function createRenderer(
 32    node: DOMElement,
 33    stylePool: StylePool,
 34  ): Renderer {
 35    // Reuse Output across frames so charCache (tokenize + grapheme clustering)
 36    // persists — most lines don't change between renders.
 37    let output: Output | undefined
 38    return options => {
 39      const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
 40        options
 41      const prevScreen = frontFrame.screen
 42      const backScreen = backFrame.screen
 43      // Read pools from the back buffer's screen — pools may be replaced
 44      // between frames (generational reset), so we can't capture them in the closure
 45      const charPool = backScreen.charPool
 46      const hyperlinkPool = backScreen.hyperlinkPool
 47  
 48      // Return empty frame if yoga node doesn't exist or layout hasn't been computed yet.
 49      // getComputedHeight() returns NaN before calculateLayout() is called.
 50      // Also check for invalid dimensions (negative, Infinity) that would cause RangeError
 51      // when creating arrays.
 52      const computedHeight = node.yogaNode?.getComputedHeight()
 53      const computedWidth = node.yogaNode?.getComputedWidth()
 54      const hasInvalidHeight =
 55        computedHeight === undefined ||
 56        !Number.isFinite(computedHeight) ||
 57        computedHeight < 0
 58      const hasInvalidWidth =
 59        computedWidth === undefined ||
 60        !Number.isFinite(computedWidth) ||
 61        computedWidth < 0
 62  
 63      if (!node.yogaNode || hasInvalidHeight || hasInvalidWidth) {
 64        // Log to help diagnose root cause (visible with --debug flag)
 65        if (node.yogaNode && (hasInvalidHeight || hasInvalidWidth)) {
 66          logForDebugging(
 67            `Invalid yoga dimensions: width=${computedWidth}, height=${computedHeight}, ` +
 68              `childNodes=${node.childNodes.length}, terminalWidth=${terminalWidth}, terminalRows=${terminalRows}`,
 69          )
 70        }
 71        return {
 72          screen: createScreen(
 73            terminalWidth,
 74            0,
 75            stylePool,
 76            charPool,
 77            hyperlinkPool,
 78          ),
 79          viewport: { width: terminalWidth, height: terminalRows },
 80          cursor: { x: 0, y: 0, visible: true },
 81        }
 82      }
 83  
 84      const width = Math.floor(node.yogaNode.getComputedWidth())
 85      const yogaHeight = Math.floor(node.yogaNode.getComputedHeight())
 86      // Alt-screen: the screen buffer IS the alt buffer — always exactly
 87      // terminalRows tall. <AlternateScreen> wraps children in <Box
 88      // height={rows} flexShrink={0}>, so yogaHeight should equal
 89      // terminalRows. But if something renders as a SIBLING of that Box
 90      // (bug: MessageSelector was outside <FullscreenLayout>), yogaHeight
 91      // exceeds rows and every assumption below (viewport +1 hack, cursor.y
 92      // clamp, log-update's heightDelta===0 fast path) breaks, desyncing
 93      // virtual/physical cursors. Clamping here enforces the invariant:
 94      // overflow writes land at y >= screen.height and setCellAt drops
 95      // them. The sibling is invisible (obvious, easy to find) instead of
 96      // corrupting the whole terminal.
 97      const height = options.altScreen ? terminalRows : yogaHeight
 98      if (options.altScreen && yogaHeight > terminalRows) {
 99        logForDebugging(
100          `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` +
101            `something is rendering outside <AlternateScreen>. Overflow clipped.`,
102          { level: 'warn' },
103        )
104      }
105      const screen =
106        backScreen ??
107        createScreen(width, height, stylePool, charPool, hyperlinkPool)
108      if (output) {
109        output.reset(width, height, screen)
110      } else {
111        output = new Output({ width, height, stylePool, screen })
112      }
113  
114      resetLayoutShifted()
115      resetScrollHint()
116      resetScrollDrainNode()
117  
118      // prevFrameContaminated: selection overlay mutated the returned screen
119      // buffer post-render (in ink.tsx), resetFramesForAltScreen() replaced it
120      // with blanks, or forceRedraw() reset it to 0×0. Blit on the NEXT frame
121      // would copy stale inverted cells / blanks / nothing. When clean, blit
122      // restores the O(unchanged) fast path for steady-state frames (spinner
123      // tick, text stream).
124      // Removing an absolute-positioned node poisons prevScreen: it may
125      // have painted over non-siblings (e.g. an overlay over a ScrollBox
126      // earlier in tree order), so their blits would restore the removed
127      // node's pixels. hasRemovedChild only shields direct siblings.
128      // Normal-flow removals don't paint cross-subtree and are fine.
129      const absoluteRemoved = consumeAbsoluteRemovedFlag()
130      renderNodeToOutput(node, output, {
131        prevScreen:
132          absoluteRemoved || options.prevFrameContaminated
133            ? undefined
134            : prevScreen,
135      })
136  
137      const renderedScreen = output.get()
138  
139      // Drain continuation: render cleared scrollbox.dirty, so next frame's
140      // root blit would skip the subtree. markDirty walks ancestors so the
141      // next frame descends. Done AFTER render so the clear-dirty at the end
142      // of renderNodeToOutput doesn't overwrite this.
143      const drainNode = getScrollDrainNode()
144      if (drainNode) markDirty(drainNode)
145  
146      return {
147        scrollHint: options.altScreen ? getScrollHint() : null,
148        scrollDrainPending: drainNode !== null,
149        screen: renderedScreen,
150        viewport: {
151          width: terminalWidth,
152          // Alt screen: fake viewport.height = rows + 1 so that
153          // shouldClearScreen()'s `screen.height >= viewport.height` check
154          // (which treats exactly-filling content as "overflows" for
155          // scrollback purposes) never fires. Alt-screen content is always
156          // exactly `rows` tall (via <Box height={rows}>) but never
157          // scrolls — the cursor.y clamp below keeps the cursor-restore
158          // from emitting an LF. With the standard diff path, every frame
159          // is incremental; no fullResetSequence_CAUSES_FLICKER.
160          height: options.altScreen ? terminalRows + 1 : terminalRows,
161        },
162        cursor: {
163          x: 0,
164          // In the alt screen, keep the cursor inside the viewport. When
165          // screen.height === terminalRows exactly (content fills the alt
166          // screen), cursor.y = screen.height would trigger log-update's
167          // cursor-restore LF at the last row, scrolling one row off the top
168          // of the alt buffer and desyncing the diff's cursor model. The
169          // cursor is hidden so its position only matters for diff coords.
170          y: options.altScreen
171            ? Math.max(0, Math.min(screen.height, terminalRows) - 1)
172            : screen.height,
173          // Hide cursor when there's dynamic output to render (only in TTY mode)
174          visible: !isTTY || screen.height === 0,
175        },
176      }
177    }
178  }