πŸ“„ File detail

ink/render-to-screen.ts

🧩 .tsπŸ“ 232 linesπŸ’Ύ 8,570 bytesπŸ“ text
← Back to All Files

🎯 Use case

This file lives under β€œink/”, which covers Ink terminal UI (layouts, TTY IO, keyboard, renderer components). On the API surface it exposes MatchPosition, renderToScreen, scanPositions, and applyPositionedHighlight β€” mainly functions, hooks, or classes. Dependencies touch lodash-es, React UI, and react-reconciler. It composes internal code from utils, dom, focus, output, and reconciler (relative imports).

Generated from folder role, exports, dependency roots, and inline comments β€” not hand-reviewed for every path.

🧠 Inline summary

import noop from 'lodash-es/noop.js' import type { ReactElement } from 'react' import { LegacyRoot } from 'react-reconciler/constants.js' import { logForDebugging } from '../utils/debug.js' import { createNode, type DOMElement } from './dom.js'

πŸ“€ Exports (heuristic)

  • MatchPosition
  • renderToScreen
  • scanPositions
  • applyPositionedHighlight

πŸ“š External import roots

Package roots from from "…" (relative paths omitted).

  • lodash-es
  • react
  • react-reconciler

πŸ–₯️ Source preview

import noop from 'lodash-es/noop.js'
import type { ReactElement } from 'react'
import { LegacyRoot } from 'react-reconciler/constants.js'
import { logForDebugging } from '../utils/debug.js'
import { createNode, type DOMElement } from './dom.js'
import { FocusManager } from './focus.js'
import Output from './output.js'
import reconciler from './reconciler.js'
import renderNodeToOutput, {
  resetLayoutShifted,
} from './render-node-to-output.js'
import {
  CellWidth,
  CharPool,
  cellAtIndex,
  createScreen,
  HyperlinkPool,
  type Screen,
  StylePool,
  setCellStyleId,
} from './screen.js'

/** Position of a match within a rendered message, relative to the message's
 *  own bounding box (row 0 = message top). Stable across scroll β€” to
 *  highlight on the real screen, add the message's screen-row offset. */
export type MatchPosition = {
  row: number
  col: number
  /** Number of CELLS the match spans (= query.length for ASCII, more
   *  for wide chars in the query). */
  len: number
}

// Shared across calls. Pools accumulate style/char interns β€” reusing them
// means later calls hit cache more. Root/container reuse saves the
// createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling β€”
// ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork.
let root: DOMElement | undefined
let container: ReturnType<typeof reconciler.createContainer> | undefined
let stylePool: StylePool | undefined
let charPool: CharPool | undefined
let hyperlinkPool: HyperlinkPool | undefined
let output: Output | undefined

const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 }
const LOG_EVERY = 20

/** Render a React element (wrapped in all contexts the component needs β€”
 *  caller's job) to an isolated Screen buffer at the given width. Returns
 *  the Screen + natural height (from yoga). Used for search: render ONE
 *  message, scan its Screen for the query, get exact (row, col) positions.
 *
 *  ~1-3ms per call (yoga alloc + calculateLayout + paint). The
 *  flushSyncWork cross-root leak measured ~0.0003ms/call growth β€” fine
 *  for on-demand single-message rendering, pathological for render-all-
 *  8k-upfront. Cache per (msg, query, width) upstream.
 *
 *  Unmounts between calls. Root/container/pools persist for reuse. */
export function renderToScreen(
  el: ReactElement,
  width: number,
): { screen: Screen; height: number } {
  if (!root) {
    root = createNode('ink-root')
    root.focusManager = new FocusManager(() => false)
    stylePool = new StylePool()
    charPool = new CharPool()
    hyperlinkPool = new HyperlinkPool()
    // @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11
    container = reconciler.createContainer(
      root,
      LegacyRoot,
      null,
      false,
      null,
      'search-render',
      noop,
      noop,
      noop,
      noop,
    )
  }

  const t0 = performance.now()
  // @ts-expect-error updateContainerSync exists but not in @types
  reconciler.updateContainerSync(el, container, null, noop)
  // @ts-expect-error flushSyncWork exists but not in @types
  reconciler.flushSyncWork()
  const t1 = performance.now()

  // Yoga layout. Root might not have a yogaNode if the tree is empty.
  root.yogaNode?.setWidth(width)
  root.yogaNode?.calculateLayout(width)
  const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0)
  const t2 = performance.now()

  // Paint to a fresh Screen. Width = given, height = yoga's natural.
  // No alt-screen, no prevScreen (every call is fresh).
  const screen = createScreen(
    width,
    Math.max(1, height), // avoid 0-height Screen (createScreen may choke)
    stylePool!,
    charPool!,
    hyperlinkPool!,
  )
  if (!output) {
    output = new Output({ width, height, stylePool: stylePool!, screen })
  } else {
    output.reset(width, height, screen)
  }
  resetLayoutShifted()
  renderNodeToOutput(root, output, { prevScreen: undefined })
  // renderNodeToOutput queues writes into Output; .get() flushes the
  // queue into the Screen's cell arrays. Without this the screen is
  // blank (constructor-zero).
  const rendered = output.get()
  const t3 = performance.now()

  // Unmount so next call gets a fresh tree. Leaves root/container/pools.
  // @ts-expect-error updateContainerSync exists but not in @types
  reconciler.updateContainerSync(null, container, null, noop)
  // @ts-expect-error flushSyncWork exists but not in @types
  reconciler.flushSyncWork()

  timing.reconcile += t1 - t0
  timing.yoga += t2 - t1
  timing.paint += t3 - t2
  if (++timing.calls % LOG_EVERY === 0) {
    const total = timing.reconcile + timing.yoga + timing.paint + timing.scan
    logForDebugging(
      `renderToScreen: ${timing.calls} calls Β· ` +
        `reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` +
        `paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms Β· ` +
        `total=${total.toFixed(1)}ms Β· avg ${(total / timing.calls).toFixed(2)}ms/call`,
    )
  }

  return { screen: rendered, height }
}

/** Scan a Screen buffer for all occurrences of query. Returns positions
 *  relative to the buffer (row 0 = buffer top). Same cell-skip logic as
 *  applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions
 *  match what the overlay highlight would find. Case-insensitive.
 *
 *  For the side-render use: this Screen is the FULL message (natural
 *  height, not viewport-clipped). Positions are stable β€” to highlight
 *  on the real screen, add the message's screen offset (lo). */
export function scanPositions(screen: Screen, query: string): MatchPosition[] {
  const lq = query.toLowerCase()
  if (!lq) return []
  const qlen = lq.length
  const w = screen.width
  const h = screen.height
  const noSelect = screen.noSelect
  const positions: MatchPosition[] = []

  const t0 = performance.now()
  for (let row = 0; row < h; row++) {
    const rowOff = row * w
    // Same text-build as applySearchHighlight. Keep in sync β€” or extract
    // to a shared helper (TODO once both are stable). codeUnitToCell
    // maps indexOf positions (code units in the LOWERCASED text) to cell
    // indices in colOf β€” surrogate pairs (emoji) and multi-unit lowercase
    // (Turkish Δ° β†’ i + U+0307) make text.length > colOf.length.
    let text = ''
    const colOf: number[] = []
    const codeUnitToCell: number[] = []
    for (let col = 0; col < w; col++) {
      const idx = rowOff + col
      const cell = cellAtIndex(screen, idx)
      if (
        cell.width === CellWidth.SpacerTail ||
        cell.width === CellWidth.SpacerHead ||
        noSelect[idx] === 1
      ) {
        continue
      }
      const lc = cell.char.toLowerCase()
      const cellIdx = colOf.length
      for (let i = 0; i < lc.length; i++) {
        codeUnitToCell.push(cellIdx)
      }
      text += lc
      colOf.push(col)
    }
    // Non-overlapping β€” same advance as applySearchHighlight.
    let pos = text.indexOf(lq)
    while (pos >= 0) {
      const startCi = codeUnitToCell[pos]!
      const endCi = codeUnitToCell[pos + qlen - 1]!
      const col = colOf[startCi]!
      const endCol = colOf[endCi]! + 1
      positions.push({ row, col, len: endCol - col })
      pos = text.indexOf(lq, pos + qlen)
    }
  }
  timing.scan += performance.now() - t0

  return positions
}

/** Write CURRENT (yellow+bold+underline) at positions[currentIdx] +
 *  rowOffset. OTHER positions are NOT styled here β€” the scan-highlight
 *  (applySearchHighlight with null hint) does inverse for all visible
 *  matches, including these. Two-layer: scan = 'you could go here',
 *  position = 'you ARE here'. Writing inverse again here would be a
 *  no-op (withInverse idempotent) but wasted work.
 *
 *  Positions are message-relative (row 0 = message top). rowOffset =
 *  message's current screen-top (lo). Clips outside [0, height). */
export function applyPositionedHighlight(
  screen: Screen,
  stylePool: StylePool,
  positions: MatchPosition[],
  rowOffset: number,
  currentIdx: number,
): boolean {
  if (currentIdx < 0 || currentIdx >= positions.length) return false
  const p = positions[currentIdx]!
  const row = p.row + rowOffset
  if (row < 0 || row >= screen.height) return false
  const transform = (id: number) => stylePool.withCurrentMatch(id)
  const rowOff = row * screen.width
  for (let col = p.col; col < p.col + p.len; col++) {
    if (col < 0 || col >= screen.width) continue
    const cell = cellAtIndex(screen, rowOff + col)
    setCellStyleId(screen, col, row, transform(cell.styleId))
  }
  return true
}