/ ink / render-to-screen.ts
render-to-screen.ts
  1  import noop from 'lodash-es/noop.js'
  2  import type { ReactElement } from 'react'
  3  import { LegacyRoot } from 'react-reconciler/constants.js'
  4  import { logForDebugging } from '../utils/debug.js'
  5  import { createNode, type DOMElement } from './dom.js'
  6  import { FocusManager } from './focus.js'
  7  import Output from './output.js'
  8  import reconciler from './reconciler.js'
  9  import renderNodeToOutput, {
 10    resetLayoutShifted,
 11  } from './render-node-to-output.js'
 12  import {
 13    CellWidth,
 14    CharPool,
 15    cellAtIndex,
 16    createScreen,
 17    HyperlinkPool,
 18    type Screen,
 19    StylePool,
 20    setCellStyleId,
 21  } from './screen.js'
 22  
 23  /** Position of a match within a rendered message, relative to the message's
 24   *  own bounding box (row 0 = message top). Stable across scroll — to
 25   *  highlight on the real screen, add the message's screen-row offset. */
 26  export type MatchPosition = {
 27    row: number
 28    col: number
 29    /** Number of CELLS the match spans (= query.length for ASCII, more
 30     *  for wide chars in the query). */
 31    len: number
 32  }
 33  
 34  // Shared across calls. Pools accumulate style/char interns — reusing them
 35  // means later calls hit cache more. Root/container reuse saves the
 36  // createContainer cost (~1ms). LegacyRoot: all work sync, no scheduling —
 37  // ConcurrentRoot's scheduler backlog leaks across roots via flushSyncWork.
 38  let root: DOMElement | undefined
 39  let container: ReturnType<typeof reconciler.createContainer> | undefined
 40  let stylePool: StylePool | undefined
 41  let charPool: CharPool | undefined
 42  let hyperlinkPool: HyperlinkPool | undefined
 43  let output: Output | undefined
 44  
 45  const timing = { reconcile: 0, yoga: 0, paint: 0, scan: 0, calls: 0 }
 46  const LOG_EVERY = 20
 47  
 48  /** Render a React element (wrapped in all contexts the component needs —
 49   *  caller's job) to an isolated Screen buffer at the given width. Returns
 50   *  the Screen + natural height (from yoga). Used for search: render ONE
 51   *  message, scan its Screen for the query, get exact (row, col) positions.
 52   *
 53   *  ~1-3ms per call (yoga alloc + calculateLayout + paint). The
 54   *  flushSyncWork cross-root leak measured ~0.0003ms/call growth — fine
 55   *  for on-demand single-message rendering, pathological for render-all-
 56   *  8k-upfront. Cache per (msg, query, width) upstream.
 57   *
 58   *  Unmounts between calls. Root/container/pools persist for reuse. */
 59  export function renderToScreen(
 60    el: ReactElement,
 61    width: number,
 62  ): { screen: Screen; height: number } {
 63    if (!root) {
 64      root = createNode('ink-root')
 65      root.focusManager = new FocusManager(() => false)
 66      stylePool = new StylePool()
 67      charPool = new CharPool()
 68      hyperlinkPool = new HyperlinkPool()
 69      // @ts-expect-error react-reconciler 0.33 takes 10 args; @types says 11
 70      container = reconciler.createContainer(
 71        root,
 72        LegacyRoot,
 73        null,
 74        false,
 75        null,
 76        'search-render',
 77        noop,
 78        noop,
 79        noop,
 80        noop,
 81      )
 82    }
 83  
 84    const t0 = performance.now()
 85    // @ts-expect-error updateContainerSync exists but not in @types
 86    reconciler.updateContainerSync(el, container, null, noop)
 87    // @ts-expect-error flushSyncWork exists but not in @types
 88    reconciler.flushSyncWork()
 89    const t1 = performance.now()
 90  
 91    // Yoga layout. Root might not have a yogaNode if the tree is empty.
 92    root.yogaNode?.setWidth(width)
 93    root.yogaNode?.calculateLayout(width)
 94    const height = Math.ceil(root.yogaNode?.getComputedHeight() ?? 0)
 95    const t2 = performance.now()
 96  
 97    // Paint to a fresh Screen. Width = given, height = yoga's natural.
 98    // No alt-screen, no prevScreen (every call is fresh).
 99    const screen = createScreen(
100      width,
101      Math.max(1, height), // avoid 0-height Screen (createScreen may choke)
102      stylePool!,
103      charPool!,
104      hyperlinkPool!,
105    )
106    if (!output) {
107      output = new Output({ width, height, stylePool: stylePool!, screen })
108    } else {
109      output.reset(width, height, screen)
110    }
111    resetLayoutShifted()
112    renderNodeToOutput(root, output, { prevScreen: undefined })
113    // renderNodeToOutput queues writes into Output; .get() flushes the
114    // queue into the Screen's cell arrays. Without this the screen is
115    // blank (constructor-zero).
116    const rendered = output.get()
117    const t3 = performance.now()
118  
119    // Unmount so next call gets a fresh tree. Leaves root/container/pools.
120    // @ts-expect-error updateContainerSync exists but not in @types
121    reconciler.updateContainerSync(null, container, null, noop)
122    // @ts-expect-error flushSyncWork exists but not in @types
123    reconciler.flushSyncWork()
124  
125    timing.reconcile += t1 - t0
126    timing.yoga += t2 - t1
127    timing.paint += t3 - t2
128    if (++timing.calls % LOG_EVERY === 0) {
129      const total = timing.reconcile + timing.yoga + timing.paint + timing.scan
130      logForDebugging(
131        `renderToScreen: ${timing.calls} calls · ` +
132          `reconcile=${timing.reconcile.toFixed(1)}ms yoga=${timing.yoga.toFixed(1)}ms ` +
133          `paint=${timing.paint.toFixed(1)}ms scan=${timing.scan.toFixed(1)}ms · ` +
134          `total=${total.toFixed(1)}ms · avg ${(total / timing.calls).toFixed(2)}ms/call`,
135      )
136    }
137  
138    return { screen: rendered, height }
139  }
140  
141  /** Scan a Screen buffer for all occurrences of query. Returns positions
142   *  relative to the buffer (row 0 = buffer top). Same cell-skip logic as
143   *  applySearchHighlight (SpacerTail/SpacerHead/noSelect) so positions
144   *  match what the overlay highlight would find. Case-insensitive.
145   *
146   *  For the side-render use: this Screen is the FULL message (natural
147   *  height, not viewport-clipped). Positions are stable — to highlight
148   *  on the real screen, add the message's screen offset (lo). */
149  export function scanPositions(screen: Screen, query: string): MatchPosition[] {
150    const lq = query.toLowerCase()
151    if (!lq) return []
152    const qlen = lq.length
153    const w = screen.width
154    const h = screen.height
155    const noSelect = screen.noSelect
156    const positions: MatchPosition[] = []
157  
158    const t0 = performance.now()
159    for (let row = 0; row < h; row++) {
160      const rowOff = row * w
161      // Same text-build as applySearchHighlight. Keep in sync — or extract
162      // to a shared helper (TODO once both are stable). codeUnitToCell
163      // maps indexOf positions (code units in the LOWERCASED text) to cell
164      // indices in colOf — surrogate pairs (emoji) and multi-unit lowercase
165      // (Turkish İ → i + U+0307) make text.length > colOf.length.
166      let text = ''
167      const colOf: number[] = []
168      const codeUnitToCell: number[] = []
169      for (let col = 0; col < w; col++) {
170        const idx = rowOff + col
171        const cell = cellAtIndex(screen, idx)
172        if (
173          cell.width === CellWidth.SpacerTail ||
174          cell.width === CellWidth.SpacerHead ||
175          noSelect[idx] === 1
176        ) {
177          continue
178        }
179        const lc = cell.char.toLowerCase()
180        const cellIdx = colOf.length
181        for (let i = 0; i < lc.length; i++) {
182          codeUnitToCell.push(cellIdx)
183        }
184        text += lc
185        colOf.push(col)
186      }
187      // Non-overlapping — same advance as applySearchHighlight.
188      let pos = text.indexOf(lq)
189      while (pos >= 0) {
190        const startCi = codeUnitToCell[pos]!
191        const endCi = codeUnitToCell[pos + qlen - 1]!
192        const col = colOf[startCi]!
193        const endCol = colOf[endCi]! + 1
194        positions.push({ row, col, len: endCol - col })
195        pos = text.indexOf(lq, pos + qlen)
196      }
197    }
198    timing.scan += performance.now() - t0
199  
200    return positions
201  }
202  
203  /** Write CURRENT (yellow+bold+underline) at positions[currentIdx] +
204   *  rowOffset. OTHER positions are NOT styled here — the scan-highlight
205   *  (applySearchHighlight with null hint) does inverse for all visible
206   *  matches, including these. Two-layer: scan = 'you could go here',
207   *  position = 'you ARE here'. Writing inverse again here would be a
208   *  no-op (withInverse idempotent) but wasted work.
209   *
210   *  Positions are message-relative (row 0 = message top). rowOffset =
211   *  message's current screen-top (lo). Clips outside [0, height). */
212  export function applyPositionedHighlight(
213    screen: Screen,
214    stylePool: StylePool,
215    positions: MatchPosition[],
216    rowOffset: number,
217    currentIdx: number,
218  ): boolean {
219    if (currentIdx < 0 || currentIdx >= positions.length) return false
220    const p = positions[currentIdx]!
221    const row = p.row + rowOffset
222    if (row < 0 || row >= screen.height) return false
223    const transform = (id: number) => stylePool.withCurrentMatch(id)
224    const rowOff = row * screen.width
225    for (let col = p.col; col < p.col + p.len; col++) {
226      if (col < 0 || col >= screen.width) continue
227      const cell = cellAtIndex(screen, rowOff + col)
228      setCellStyleId(screen, col, row, transform(cell.styleId))
229    }
230    return true
231  }