File size: 8,570 Bytes
064bfd6 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | 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
}
|