forked from ultraworkers/claw-code
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrender-to-screen.ts
More file actions
231 lines (217 loc) · 8.37 KB
/
Copy pathrender-to-screen.ts
File metadata and controls
231 lines (217 loc) · 8.37 KB
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
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
}