@silvery/term 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +54 -0
- package/src/adapters/canvas-adapter.ts +356 -0
- package/src/adapters/dom-adapter.ts +452 -0
- package/src/adapters/flexily-zero-adapter.ts +368 -0
- package/src/adapters/terminal-adapter.ts +305 -0
- package/src/adapters/yoga-adapter.ts +370 -0
- package/src/ansi/ansi.ts +251 -0
- package/src/ansi/constants.ts +76 -0
- package/src/ansi/detection.ts +441 -0
- package/src/ansi/hyperlink.ts +38 -0
- package/src/ansi/index.ts +201 -0
- package/src/ansi/patch-console.ts +159 -0
- package/src/ansi/sgr-codes.ts +34 -0
- package/src/ansi/storybook.ts +209 -0
- package/src/ansi/term.ts +724 -0
- package/src/ansi/types.ts +202 -0
- package/src/ansi/underline.ts +156 -0
- package/src/ansi/utils.ts +65 -0
- package/src/ansi-sanitize.ts +509 -0
- package/src/app.ts +571 -0
- package/src/bound-term.ts +94 -0
- package/src/bracketed-paste.ts +75 -0
- package/src/browser-renderer.ts +174 -0
- package/src/buffer.ts +1984 -0
- package/src/clipboard.ts +74 -0
- package/src/cursor-query.ts +85 -0
- package/src/device-attrs.ts +228 -0
- package/src/devtools.ts +123 -0
- package/src/dom/index.ts +194 -0
- package/src/errors.ts +39 -0
- package/src/focus-reporting.ts +48 -0
- package/src/hit-registry-core.ts +228 -0
- package/src/hit-registry.ts +176 -0
- package/src/index.ts +458 -0
- package/src/input.ts +119 -0
- package/src/inspector.ts +155 -0
- package/src/kitty-detect.ts +95 -0
- package/src/kitty-manager.ts +160 -0
- package/src/layout-engine.ts +296 -0
- package/src/layout.ts +26 -0
- package/src/measurer.ts +74 -0
- package/src/mode-query.ts +106 -0
- package/src/mouse-events.ts +419 -0
- package/src/mouse.ts +83 -0
- package/src/non-tty.ts +223 -0
- package/src/osc-markers.ts +32 -0
- package/src/osc-palette.ts +169 -0
- package/src/output.ts +406 -0
- package/src/pane-manager.ts +248 -0
- package/src/pipeline/CLAUDE.md +587 -0
- package/src/pipeline/content-phase-adapter.ts +976 -0
- package/src/pipeline/content-phase.ts +1765 -0
- package/src/pipeline/helpers.ts +42 -0
- package/src/pipeline/index.ts +416 -0
- package/src/pipeline/layout-phase.ts +686 -0
- package/src/pipeline/measure-phase.ts +198 -0
- package/src/pipeline/measure-stats.ts +21 -0
- package/src/pipeline/output-phase.ts +2593 -0
- package/src/pipeline/render-box.ts +343 -0
- package/src/pipeline/render-helpers.ts +243 -0
- package/src/pipeline/render-text.ts +1255 -0
- package/src/pipeline/types.ts +161 -0
- package/src/pipeline.ts +29 -0
- package/src/pixel-size.ts +119 -0
- package/src/render-adapter.ts +179 -0
- package/src/renderer.ts +1330 -0
- package/src/runtime/create-app.tsx +1845 -0
- package/src/runtime/create-buffer.ts +18 -0
- package/src/runtime/create-runtime.ts +325 -0
- package/src/runtime/diff.ts +56 -0
- package/src/runtime/event-handlers.ts +254 -0
- package/src/runtime/index.ts +119 -0
- package/src/runtime/keys.ts +8 -0
- package/src/runtime/layout.ts +164 -0
- package/src/runtime/run.tsx +318 -0
- package/src/runtime/term-provider.ts +399 -0
- package/src/runtime/terminal-lifecycle.ts +246 -0
- package/src/runtime/tick.ts +219 -0
- package/src/runtime/types.ts +210 -0
- package/src/scheduler.ts +723 -0
- package/src/screenshot.ts +57 -0
- package/src/scroll-region.ts +69 -0
- package/src/scroll-utils.ts +97 -0
- package/src/term-def.ts +267 -0
- package/src/terminal-caps.ts +5 -0
- package/src/terminal-colors.ts +216 -0
- package/src/termtest.ts +224 -0
- package/src/text-sizing.ts +109 -0
- package/src/toolbelt/index.ts +72 -0
- package/src/unicode.ts +1763 -0
- package/src/xterm/index.ts +491 -0
- package/src/xterm/xterm-provider.ts +204 -0
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Content Phase (Adapter-aware) -- DIVERGENT RENDERER
|
|
3
|
+
*
|
|
4
|
+
* A simplified, adapter-agnostic content renderer that renders the full node
|
|
5
|
+
* tree to a RenderBuffer every frame. Used by `executeRenderAdapter()` for
|
|
6
|
+
* non-terminal targets (xterm.js web showcases, canvas, etc.) where the main
|
|
7
|
+
* terminal-optimized content phase cannot be used.
|
|
8
|
+
*
|
|
9
|
+
* Relationship to content-phase.ts:
|
|
10
|
+
* content-phase.ts is the primary renderer for terminal output. It has
|
|
11
|
+
* incremental rendering (dirty flags, buffer cloning, fast-path skips),
|
|
12
|
+
* bg inheritance via findInheritedBg(), ANSI-aware text rendering, theme
|
|
13
|
+
* context propagation, region clearing, excess area cleanup, descendant
|
|
14
|
+
* overflow detection, and extensive instrumentation/STRICT mode support.
|
|
15
|
+
*
|
|
16
|
+
* This file is a parallel implementation that re-implements the same tree
|
|
17
|
+
* traversal and rendering logic but against the abstract RenderBuffer
|
|
18
|
+
* interface (drawChar/drawText/fillRect) instead of TerminalBuffer directly.
|
|
19
|
+
*
|
|
20
|
+
* Why it exists:
|
|
21
|
+
* The RenderAdapter abstraction (see render-adapter.ts) allows silvery to
|
|
22
|
+
* target different backends -- terminal, xterm.js, canvas. The main
|
|
23
|
+
* content-phase.ts is tightly coupled to TerminalBuffer (cell-level access,
|
|
24
|
+
* getCellBg, scrollRegion, packed metadata). This adapter version works with
|
|
25
|
+
* any RenderBuffer implementation, making it usable for web showcases and
|
|
26
|
+
* future non-terminal targets.
|
|
27
|
+
*
|
|
28
|
+
* Known divergences from content-phase.ts:
|
|
29
|
+
* - No incremental rendering: full re-render every frame (no dirty flag
|
|
30
|
+
* evaluation, no buffer cloning, no fast-path skips)
|
|
31
|
+
* - No bg inheritance via findInheritedBg() for text -- uses a simpler
|
|
32
|
+
* ancestor walk (findAncestorBg) that doesn't handle all edge cases
|
|
33
|
+
* - No theme context propagation (pushContextTheme/popContextTheme)
|
|
34
|
+
* - No region clearing or excess area cleanup (not needed without
|
|
35
|
+
* incremental rendering since the buffer starts fresh each frame)
|
|
36
|
+
* - No instrumentation, STRICT mode, or diagnostic support
|
|
37
|
+
* - No ANSI-aware text rendering (collectTextContent is plain string
|
|
38
|
+
* concatenation, not the segment-based BgSegment approach)
|
|
39
|
+
* - No absolute/sticky incremental rendering optimizations in renderNormalChildren
|
|
40
|
+
* (three-pass paint order is implemented but without hasPrevBuffer/ancestorCleared cascading)
|
|
41
|
+
*
|
|
42
|
+
* Future direction:
|
|
43
|
+
* The xterm-unification design (docs/design/xterm-unification.md) proposes
|
|
44
|
+
* eliminating this file by making xterm.js use the main terminal pipeline
|
|
45
|
+
* via createXtermProvider(). Since xterm.js is a terminal emulator that
|
|
46
|
+
* accepts ANSI output, it can use the real content-phase.ts + output-phase.ts
|
|
47
|
+
* and benefit from incremental rendering. Until then, this file must be
|
|
48
|
+
* maintained in parallel -- any rendering feature added to content-phase.ts
|
|
49
|
+
* may need a corresponding (simplified) implementation here.
|
|
50
|
+
*/
|
|
51
|
+
|
|
52
|
+
import { type RenderBuffer, type RenderStyle, getRenderAdapter, hasRenderAdapter } from "../render-adapter"
|
|
53
|
+
import type { BoxProps, TeaNode, Rect, TextProps } from "@silvery/tea/types"
|
|
54
|
+
import { getBorderSize, getPadding } from "./helpers"
|
|
55
|
+
import { displayWidth } from "../unicode"
|
|
56
|
+
import { formatTextLines } from "./render-text"
|
|
57
|
+
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// Main Entry Point
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Render all nodes to a RenderBuffer using the current adapter.
|
|
64
|
+
*
|
|
65
|
+
* @param root The root SilveryNode
|
|
66
|
+
* @returns A RenderBuffer with the rendered content
|
|
67
|
+
*/
|
|
68
|
+
export function contentPhaseAdapter(root: TeaNode): RenderBuffer {
|
|
69
|
+
if (!hasRenderAdapter()) {
|
|
70
|
+
throw new Error("contentPhaseAdapter called without a render adapter set")
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const layout = root.contentRect
|
|
74
|
+
if (!layout) {
|
|
75
|
+
throw new Error("contentPhaseAdapter called before layout phase")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const adapter = getRenderAdapter()
|
|
79
|
+
const buffer = adapter.createBuffer(layout.width, layout.height)
|
|
80
|
+
|
|
81
|
+
renderNodeToBuffer(root, buffer)
|
|
82
|
+
return buffer
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ============================================================================
|
|
86
|
+
// Node Rendering
|
|
87
|
+
// ============================================================================
|
|
88
|
+
|
|
89
|
+
/** Clip bounds for vertical and optional horizontal clipping. */
|
|
90
|
+
interface ClipRect {
|
|
91
|
+
top: number
|
|
92
|
+
bottom: number
|
|
93
|
+
left?: number
|
|
94
|
+
right?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function renderNodeToBuffer(node: TeaNode, buffer: RenderBuffer, scrollOffset = 0, clipBounds?: ClipRect): void {
|
|
98
|
+
const layout = node.contentRect
|
|
99
|
+
if (!layout) return
|
|
100
|
+
|
|
101
|
+
// Skip nodes without layout (raw text and virtual text nodes)
|
|
102
|
+
if (!node.layoutNode) return
|
|
103
|
+
|
|
104
|
+
// Skip hidden nodes (Suspense support)
|
|
105
|
+
if (node.hidden) return
|
|
106
|
+
|
|
107
|
+
const props = node.props as BoxProps & TextProps
|
|
108
|
+
|
|
109
|
+
// Skip display="none" nodes
|
|
110
|
+
if (props.display === "none") return
|
|
111
|
+
|
|
112
|
+
// Check if this is a scrollable container
|
|
113
|
+
const isScrollContainer = props.overflow === "scroll" && node.scrollState
|
|
114
|
+
|
|
115
|
+
// Render based on node type
|
|
116
|
+
if (node.type === "silvery-box") {
|
|
117
|
+
renderBox(node, buffer, layout, props, clipBounds, scrollOffset)
|
|
118
|
+
|
|
119
|
+
// Scroll indicators
|
|
120
|
+
if (isScrollContainer && node.scrollState) {
|
|
121
|
+
renderScrollIndicators(node, buffer, layout, props, node.scrollState)
|
|
122
|
+
}
|
|
123
|
+
} else if (node.type === "silvery-text") {
|
|
124
|
+
renderText(node, buffer, layout, props, scrollOffset, clipBounds)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Render children
|
|
128
|
+
if (isScrollContainer && node.scrollState) {
|
|
129
|
+
renderScrollContainerChildren(node, buffer, props, clipBounds)
|
|
130
|
+
} else {
|
|
131
|
+
renderNormalChildren(node, buffer, scrollOffset, props, clipBounds)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Render outline AFTER children — outline overlaps content at edges
|
|
135
|
+
if (node.type === "silvery-box" && props.outlineStyle) {
|
|
136
|
+
const { x, width, height } = layout
|
|
137
|
+
const outlineY = layout.y - scrollOffset
|
|
138
|
+
renderOutlineAdapter(buffer, x, outlineY, width, height, props, clipBounds)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Clear content dirty flag
|
|
142
|
+
node.contentDirty = false
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ============================================================================
|
|
146
|
+
// Box Rendering
|
|
147
|
+
// ============================================================================
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Render a Box node.
|
|
151
|
+
*/
|
|
152
|
+
function renderBox(
|
|
153
|
+
_node: TeaNode,
|
|
154
|
+
buffer: RenderBuffer,
|
|
155
|
+
layout: Rect,
|
|
156
|
+
props: BoxProps,
|
|
157
|
+
clipBounds?: ClipRect,
|
|
158
|
+
scrollOffset = 0,
|
|
159
|
+
): void {
|
|
160
|
+
const { x, width, height } = layout
|
|
161
|
+
const y = layout.y - scrollOffset
|
|
162
|
+
|
|
163
|
+
// Skip if completely outside clip bounds
|
|
164
|
+
if (clipBounds) {
|
|
165
|
+
if (y + height <= clipBounds.top || y >= clipBounds.bottom) return
|
|
166
|
+
if (clipBounds.left !== undefined && clipBounds.right !== undefined) {
|
|
167
|
+
if (x + width <= clipBounds.left || x >= clipBounds.right) return
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Fill background if set
|
|
172
|
+
if (props.backgroundColor) {
|
|
173
|
+
const style: RenderStyle = { bg: props.backgroundColor }
|
|
174
|
+
|
|
175
|
+
if (clipBounds) {
|
|
176
|
+
const clippedY = Math.max(y, clipBounds.top)
|
|
177
|
+
const clippedHeight = Math.min(y + height, clipBounds.bottom) - clippedY
|
|
178
|
+
const clippedX = clipBounds.left !== undefined ? Math.max(x, clipBounds.left) : x
|
|
179
|
+
const clippedWidth =
|
|
180
|
+
clipBounds.right !== undefined ? Math.min(x + width, clipBounds.right) - clippedX : width - (clippedX - x)
|
|
181
|
+
if (clippedHeight > 0 && clippedWidth > 0) {
|
|
182
|
+
buffer.fillRect(clippedX, clippedY, clippedWidth, clippedHeight, style)
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
buffer.fillRect(x, y, width, height, style)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Render border if set
|
|
190
|
+
if (props.borderStyle) {
|
|
191
|
+
renderBorder(buffer, x, y, width, height, props, clipBounds)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Render a border around a box.
|
|
197
|
+
*/
|
|
198
|
+
function renderBorder(
|
|
199
|
+
buffer: RenderBuffer,
|
|
200
|
+
x: number,
|
|
201
|
+
y: number,
|
|
202
|
+
width: number,
|
|
203
|
+
height: number,
|
|
204
|
+
props: BoxProps,
|
|
205
|
+
clipBounds?: ClipRect,
|
|
206
|
+
): void {
|
|
207
|
+
const adapter = getRenderAdapter()
|
|
208
|
+
const chars = adapter.getBorderChars(props.borderStyle ?? "single")
|
|
209
|
+
const style: RenderStyle = props.borderColor ? { fg: props.borderColor } : {}
|
|
210
|
+
|
|
211
|
+
const showTop = props.borderTop !== false
|
|
212
|
+
const showBottom = props.borderBottom !== false
|
|
213
|
+
const showLeft = props.borderLeft !== false
|
|
214
|
+
const showRight = props.borderRight !== false
|
|
215
|
+
|
|
216
|
+
const isRowVisible = (row: number): boolean =>
|
|
217
|
+
clipBounds ? row >= clipBounds.top && row < clipBounds.bottom && buffer.inBounds(0, row) : buffer.inBounds(0, row)
|
|
218
|
+
|
|
219
|
+
// Top border
|
|
220
|
+
if (showTop && isRowVisible(y)) {
|
|
221
|
+
renderHorizontalBorder(
|
|
222
|
+
buffer,
|
|
223
|
+
x,
|
|
224
|
+
y,
|
|
225
|
+
width,
|
|
226
|
+
showLeft,
|
|
227
|
+
showRight,
|
|
228
|
+
chars.topLeft,
|
|
229
|
+
chars.topRight,
|
|
230
|
+
chars.horizontal,
|
|
231
|
+
style,
|
|
232
|
+
clipBounds,
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Side borders — extend range when top/bottom borders are hidden
|
|
237
|
+
const rightVertical = chars.rightVertical ?? chars.vertical
|
|
238
|
+
const sideStart = showTop ? y + 1 : y
|
|
239
|
+
const sideEnd = showBottom ? y + height - 1 : y + height
|
|
240
|
+
renderSideBorders(
|
|
241
|
+
buffer,
|
|
242
|
+
x,
|
|
243
|
+
width,
|
|
244
|
+
sideStart,
|
|
245
|
+
sideEnd,
|
|
246
|
+
showLeft,
|
|
247
|
+
showRight,
|
|
248
|
+
chars.vertical,
|
|
249
|
+
rightVertical,
|
|
250
|
+
style,
|
|
251
|
+
isRowVisible,
|
|
252
|
+
clipBounds,
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
// Bottom border
|
|
256
|
+
const bottomHorizontal = chars.bottomHorizontal ?? chars.horizontal
|
|
257
|
+
const bottomY = y + height - 1
|
|
258
|
+
if (showBottom && isRowVisible(bottomY)) {
|
|
259
|
+
renderHorizontalBorder(
|
|
260
|
+
buffer,
|
|
261
|
+
x,
|
|
262
|
+
bottomY,
|
|
263
|
+
width,
|
|
264
|
+
showLeft,
|
|
265
|
+
showRight,
|
|
266
|
+
chars.bottomLeft,
|
|
267
|
+
chars.bottomRight,
|
|
268
|
+
bottomHorizontal,
|
|
269
|
+
style,
|
|
270
|
+
clipBounds,
|
|
271
|
+
)
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function renderHorizontalBorder(
|
|
276
|
+
buffer: RenderBuffer,
|
|
277
|
+
x: number,
|
|
278
|
+
row: number,
|
|
279
|
+
width: number,
|
|
280
|
+
showLeft: boolean,
|
|
281
|
+
showRight: boolean,
|
|
282
|
+
leftCorner: string,
|
|
283
|
+
rightCorner: string,
|
|
284
|
+
horizontal: string,
|
|
285
|
+
style: RenderStyle,
|
|
286
|
+
clipBounds?: ClipRect,
|
|
287
|
+
): void {
|
|
288
|
+
const clipLeft = clipBounds?.left ?? -Infinity
|
|
289
|
+
const clipRight = clipBounds?.right ?? Infinity
|
|
290
|
+
if (showLeft && x >= clipLeft && x < clipRight) buffer.drawChar(x, row, leftCorner, style)
|
|
291
|
+
for (let col = x + 1; col < x + width - 1; col++) {
|
|
292
|
+
if (col >= clipLeft && col < clipRight && buffer.inBounds(col, row)) {
|
|
293
|
+
buffer.drawChar(col, row, horizontal, style)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const rightCol = x + width - 1
|
|
297
|
+
if (showRight && rightCol >= clipLeft && rightCol < clipRight && buffer.inBounds(rightCol, row)) {
|
|
298
|
+
buffer.drawChar(rightCol, row, rightCorner, style)
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function renderSideBorders(
|
|
303
|
+
buffer: RenderBuffer,
|
|
304
|
+
x: number,
|
|
305
|
+
width: number,
|
|
306
|
+
startRow: number,
|
|
307
|
+
endRow: number,
|
|
308
|
+
showLeft: boolean,
|
|
309
|
+
showRight: boolean,
|
|
310
|
+
leftVertical: string,
|
|
311
|
+
rightVertical: string,
|
|
312
|
+
style: RenderStyle,
|
|
313
|
+
isRowVisible: (row: number) => boolean,
|
|
314
|
+
clipBounds?: ClipRect,
|
|
315
|
+
): void {
|
|
316
|
+
const clipLeft = clipBounds?.left ?? -Infinity
|
|
317
|
+
const clipRight = clipBounds?.right ?? Infinity
|
|
318
|
+
for (let row = startRow; row < endRow; row++) {
|
|
319
|
+
if (!isRowVisible(row)) continue
|
|
320
|
+
if (showLeft && x >= clipLeft && x < clipRight) buffer.drawChar(x, row, leftVertical, style)
|
|
321
|
+
const rightCol = x + width - 1
|
|
322
|
+
if (showRight && rightCol >= clipLeft && rightCol < clipRight && buffer.inBounds(rightCol, row)) {
|
|
323
|
+
buffer.drawChar(rightCol, row, rightVertical, style)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ============================================================================
|
|
329
|
+
// Outline Rendering
|
|
330
|
+
// ============================================================================
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Render an outline around a box (adapter version).
|
|
334
|
+
*
|
|
335
|
+
* Unlike borders, outlines do NOT affect layout dimensions. They draw border
|
|
336
|
+
* characters that OVERLAP the content area at the node's screen rect edges.
|
|
337
|
+
*/
|
|
338
|
+
function renderOutlineAdapter(
|
|
339
|
+
buffer: RenderBuffer,
|
|
340
|
+
x: number,
|
|
341
|
+
y: number,
|
|
342
|
+
width: number,
|
|
343
|
+
height: number,
|
|
344
|
+
props: BoxProps,
|
|
345
|
+
clipBounds?: ClipRect,
|
|
346
|
+
): void {
|
|
347
|
+
const adapter = getRenderAdapter()
|
|
348
|
+
const chars = adapter.getBorderChars(props.outlineStyle ?? "single")
|
|
349
|
+
const style: RenderStyle = {}
|
|
350
|
+
if (props.outlineColor) style.fg = props.outlineColor
|
|
351
|
+
if (props.outlineDimColor) style.attrs = { dim: true }
|
|
352
|
+
|
|
353
|
+
const showTop = props.outlineTop !== false
|
|
354
|
+
const showBottom = props.outlineBottom !== false
|
|
355
|
+
const showLeft = props.outlineLeft !== false
|
|
356
|
+
const showRight = props.outlineRight !== false
|
|
357
|
+
|
|
358
|
+
const isRowVisible = (row: number): boolean =>
|
|
359
|
+
clipBounds ? row >= clipBounds.top && row < clipBounds.bottom && buffer.inBounds(0, row) : buffer.inBounds(0, row)
|
|
360
|
+
|
|
361
|
+
// Top border
|
|
362
|
+
if (showTop && isRowVisible(y)) {
|
|
363
|
+
renderHorizontalBorder(
|
|
364
|
+
buffer,
|
|
365
|
+
x,
|
|
366
|
+
y,
|
|
367
|
+
width,
|
|
368
|
+
showLeft,
|
|
369
|
+
showRight,
|
|
370
|
+
chars.topLeft,
|
|
371
|
+
chars.topRight,
|
|
372
|
+
chars.horizontal,
|
|
373
|
+
style,
|
|
374
|
+
clipBounds,
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Side borders — extend range when top/bottom are hidden
|
|
379
|
+
const outRightVertical = chars.rightVertical ?? chars.vertical
|
|
380
|
+
const sideStart = showTop ? y + 1 : y
|
|
381
|
+
const sideEnd = showBottom ? y + height - 1 : y + height
|
|
382
|
+
renderSideBorders(
|
|
383
|
+
buffer,
|
|
384
|
+
x,
|
|
385
|
+
width,
|
|
386
|
+
sideStart,
|
|
387
|
+
sideEnd,
|
|
388
|
+
showLeft,
|
|
389
|
+
showRight,
|
|
390
|
+
chars.vertical,
|
|
391
|
+
outRightVertical,
|
|
392
|
+
style,
|
|
393
|
+
isRowVisible,
|
|
394
|
+
clipBounds,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
// Bottom border
|
|
398
|
+
const outBottomHorizontal = chars.bottomHorizontal ?? chars.horizontal
|
|
399
|
+
const bottomY = y + height - 1
|
|
400
|
+
if (showBottom && isRowVisible(bottomY)) {
|
|
401
|
+
renderHorizontalBorder(
|
|
402
|
+
buffer,
|
|
403
|
+
x,
|
|
404
|
+
bottomY,
|
|
405
|
+
width,
|
|
406
|
+
showLeft,
|
|
407
|
+
showRight,
|
|
408
|
+
chars.bottomLeft,
|
|
409
|
+
chars.bottomRight,
|
|
410
|
+
outBottomHorizontal,
|
|
411
|
+
style,
|
|
412
|
+
clipBounds,
|
|
413
|
+
)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ============================================================================
|
|
418
|
+
// Text Rendering
|
|
419
|
+
// ============================================================================
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Walk the parent chain to find the nearest ancestor Box with backgroundColor.
|
|
423
|
+
* Mirrors findInheritedBg() in content-phase.ts.
|
|
424
|
+
*/
|
|
425
|
+
function findAncestorBg(node: TeaNode): string | undefined {
|
|
426
|
+
let current = node.parent
|
|
427
|
+
while (current) {
|
|
428
|
+
const bg = (current.props as BoxProps).backgroundColor
|
|
429
|
+
if (bg) return bg
|
|
430
|
+
current = current.parent
|
|
431
|
+
}
|
|
432
|
+
return undefined
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** A segment of text with its resolved style. */
|
|
436
|
+
interface StyledSegment {
|
|
437
|
+
text: string
|
|
438
|
+
style: RenderStyle
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Style context for nested Text style inheritance (mirrors render-text.ts StyleContext). */
|
|
442
|
+
interface AdapterStyleContext {
|
|
443
|
+
color?: string
|
|
444
|
+
bold?: boolean
|
|
445
|
+
dim?: boolean
|
|
446
|
+
italic?: boolean
|
|
447
|
+
underline?: boolean
|
|
448
|
+
underlineStyle?: "single" | "double" | "curly" | "dotted" | "dashed"
|
|
449
|
+
underlineColor?: string
|
|
450
|
+
inverse?: boolean
|
|
451
|
+
strikethrough?: boolean
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** Merge child TextProps into parent style context. Child values override parent. */
|
|
455
|
+
function mergeAdapterStyleContext(parent: AdapterStyleContext, childProps: TextProps): AdapterStyleContext {
|
|
456
|
+
return {
|
|
457
|
+
color: childProps.color ?? parent.color,
|
|
458
|
+
bold: childProps.bold ?? parent.bold,
|
|
459
|
+
dim: childProps.dim ?? (childProps as any).dimColor ?? parent.dim,
|
|
460
|
+
italic: childProps.italic ?? parent.italic,
|
|
461
|
+
underline: childProps.underline ?? parent.underline,
|
|
462
|
+
underlineStyle: (childProps.underlineStyle as AdapterStyleContext["underlineStyle"]) ?? parent.underlineStyle,
|
|
463
|
+
underlineColor: childProps.underlineColor ?? parent.underlineColor,
|
|
464
|
+
inverse: childProps.inverse ?? parent.inverse,
|
|
465
|
+
strikethrough: childProps.strikethrough ?? parent.strikethrough,
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Build a RenderStyle from a style context and inherited bg. */
|
|
470
|
+
function contextToRenderStyle(ctx: AdapterStyleContext, bg?: string): RenderStyle {
|
|
471
|
+
return {
|
|
472
|
+
fg: ctx.color ?? undefined,
|
|
473
|
+
bg: bg ?? undefined,
|
|
474
|
+
attrs: {
|
|
475
|
+
bold: ctx.bold,
|
|
476
|
+
dim: ctx.dim,
|
|
477
|
+
italic: ctx.italic,
|
|
478
|
+
underline: ctx.underline,
|
|
479
|
+
underlineStyle: ctx.underlineStyle,
|
|
480
|
+
underlineColor: ctx.underlineColor,
|
|
481
|
+
strikethrough: ctx.strikethrough,
|
|
482
|
+
inverse: ctx.inverse,
|
|
483
|
+
},
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* Collect styled text segments from a node tree.
|
|
489
|
+
*
|
|
490
|
+
* Walks the tree like render-text.ts collectTextContent but instead of embedding
|
|
491
|
+
* ANSI codes, returns an array of { text, style } segments. The adapter renders
|
|
492
|
+
* each segment with its own RenderStyle via drawText — no ANSI parsing needed.
|
|
493
|
+
*
|
|
494
|
+
* Handles: nested Text style push/pop, internal_transform, display="none" skipping.
|
|
495
|
+
*/
|
|
496
|
+
function collectStyledSegments(
|
|
497
|
+
node: TeaNode,
|
|
498
|
+
parentContext: AdapterStyleContext,
|
|
499
|
+
inheritedBg: string | undefined,
|
|
500
|
+
segments: StyledSegment[],
|
|
501
|
+
): void {
|
|
502
|
+
// Raw text nodes — emit a segment with the current style
|
|
503
|
+
if (node.textContent !== undefined) {
|
|
504
|
+
if (node.textContent.length > 0) {
|
|
505
|
+
segments.push({
|
|
506
|
+
text: node.textContent,
|
|
507
|
+
style: contextToRenderStyle(parentContext, inheritedBg),
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
514
|
+
const child = node.children[i]!
|
|
515
|
+
const childProps = child.props as TextProps & BoxProps
|
|
516
|
+
|
|
517
|
+
// Skip display="none" children
|
|
518
|
+
if (childProps?.display === "none") continue
|
|
519
|
+
|
|
520
|
+
// Skip hidden children (Suspense)
|
|
521
|
+
if (child.hidden) continue
|
|
522
|
+
|
|
523
|
+
// Nested virtual Text node with style props
|
|
524
|
+
if (child.type === "silvery-text" && child.props && !child.layoutNode) {
|
|
525
|
+
const childContext = mergeAdapterStyleContext(parentContext, childProps)
|
|
526
|
+
|
|
527
|
+
// Check for internal_transform
|
|
528
|
+
const childTransform = (childProps as any).internal_transform as
|
|
529
|
+
| ((text: string, index: number) => string)
|
|
530
|
+
| undefined
|
|
531
|
+
|
|
532
|
+
if (childTransform) {
|
|
533
|
+
// Collect child's plain text first, apply transform, then emit as styled segment
|
|
534
|
+
const plainText = collectPlainTextAdapter(child)
|
|
535
|
+
if (plainText.length > 0) {
|
|
536
|
+
const transformed = childTransform(plainText, i)
|
|
537
|
+
if (transformed.length > 0) {
|
|
538
|
+
segments.push({
|
|
539
|
+
text: transformed,
|
|
540
|
+
style: contextToRenderStyle(childContext, inheritedBg),
|
|
541
|
+
})
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
} else {
|
|
545
|
+
// Recurse into children with merged style context
|
|
546
|
+
collectStyledSegments(child, childContext, inheritedBg, segments)
|
|
547
|
+
}
|
|
548
|
+
} else {
|
|
549
|
+
// Not a styled Text node — recurse with parent context
|
|
550
|
+
collectStyledSegments(child, parentContext, inheritedBg, segments)
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Collect plain text from a node tree (no styles). Used for internal_transform
|
|
557
|
+
* application which needs the full concatenated text before transformation.
|
|
558
|
+
*/
|
|
559
|
+
function collectPlainTextAdapter(node: TeaNode): string {
|
|
560
|
+
if (node.textContent !== undefined) return node.textContent
|
|
561
|
+
let result = ""
|
|
562
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
563
|
+
const child = node.children[i]!
|
|
564
|
+
const childProps = child.props as TextProps & BoxProps
|
|
565
|
+
if (childProps?.display === "none") continue
|
|
566
|
+
if (child.hidden) continue
|
|
567
|
+
let childText = collectPlainTextAdapter(child)
|
|
568
|
+
if (childText.length > 0 && (child.props as any)?.internal_transform) {
|
|
569
|
+
childText = (child.props as any).internal_transform(childText, i)
|
|
570
|
+
}
|
|
571
|
+
result += childText
|
|
572
|
+
}
|
|
573
|
+
return result
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Render a Text node.
|
|
578
|
+
*/
|
|
579
|
+
function renderText(
|
|
580
|
+
node: TeaNode,
|
|
581
|
+
buffer: RenderBuffer,
|
|
582
|
+
layout: Rect,
|
|
583
|
+
props: TextProps,
|
|
584
|
+
scrollOffset = 0,
|
|
585
|
+
clipBounds?: ClipRect,
|
|
586
|
+
): void {
|
|
587
|
+
const { x, width: layoutWidth } = layout
|
|
588
|
+
const y = layout.y - scrollOffset
|
|
589
|
+
|
|
590
|
+
// Build root style context from the Text node's own props
|
|
591
|
+
const rootContext: AdapterStyleContext = {
|
|
592
|
+
color: props.color ?? undefined,
|
|
593
|
+
bold: props.bold,
|
|
594
|
+
dim: props.dim,
|
|
595
|
+
italic: props.italic,
|
|
596
|
+
underline: props.underline,
|
|
597
|
+
underlineStyle: props.underlineStyle as AdapterStyleContext["underlineStyle"],
|
|
598
|
+
underlineColor: props.underlineColor ?? undefined,
|
|
599
|
+
inverse: props.inverse,
|
|
600
|
+
strikethrough: props.strikethrough,
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Inherit bg from nearest ancestor Box with backgroundColor
|
|
604
|
+
const inheritedBg = props.backgroundColor ?? findAncestorBg(node)
|
|
605
|
+
|
|
606
|
+
// Collect styled segments from all children
|
|
607
|
+
const segments: StyledSegment[] = []
|
|
608
|
+
collectStyledSegments(node, rootContext, inheritedBg, segments)
|
|
609
|
+
|
|
610
|
+
// Build flat text for formatTextLines (wrapping/truncation)
|
|
611
|
+
const text = segments.map((s) => s.text).join("")
|
|
612
|
+
if (!text) return
|
|
613
|
+
|
|
614
|
+
// Skip if outside vertical clip bounds
|
|
615
|
+
if (clipBounds && (y < clipBounds.top || y >= clipBounds.bottom)) {
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Determine the maximum column for text rendering.
|
|
620
|
+
let maxCol = x + layoutWidth
|
|
621
|
+
if (clipBounds?.right !== undefined) {
|
|
622
|
+
maxCol = Math.min(maxCol, clipBounds.right)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Determine the starting column (horizontal clip from left)
|
|
626
|
+
let startCol = x
|
|
627
|
+
if (clipBounds?.left !== undefined) {
|
|
628
|
+
startCol = Math.max(startCol, clipBounds.left)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Skip if entirely clipped horizontally
|
|
632
|
+
if (startCol >= maxCol) return
|
|
633
|
+
|
|
634
|
+
// Format text into lines (handles wrapping, truncation, newlines)
|
|
635
|
+
const availableWidth = maxCol - x
|
|
636
|
+
const lines = formatTextLines(text, availableWidth, props.wrap)
|
|
637
|
+
|
|
638
|
+
// If all segments have the same style (common case), use fast path
|
|
639
|
+
if (segments.length <= 1) {
|
|
640
|
+
const style = segments.length === 1 ? segments[0]!.style : contextToRenderStyle(rootContext, inheritedBg)
|
|
641
|
+
for (let i = 0; i < lines.length; i++) {
|
|
642
|
+
const lineY = y + i
|
|
643
|
+
if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) continue
|
|
644
|
+
if (!buffer.inBounds(0, lineY)) continue
|
|
645
|
+
const truncated = truncateToWidth(lines[i]!, availableWidth)
|
|
646
|
+
if (truncated) {
|
|
647
|
+
buffer.drawText(x, lineY, truncated, style)
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
return
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Multi-segment path: render each line with per-character style lookup.
|
|
654
|
+
// Build a character-to-segment index for the flat text.
|
|
655
|
+
const segmentForChar: number[] = new Array(text.length)
|
|
656
|
+
let charIdx = 0
|
|
657
|
+
for (let s = 0; s < segments.length; s++) {
|
|
658
|
+
const segText = segments[s]!.text
|
|
659
|
+
for (let j = 0; j < segText.length; j++) {
|
|
660
|
+
segmentForChar[charIdx++] = s
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
// Track how far we've consumed in the flat text across lines
|
|
665
|
+
let flatOffset = 0
|
|
666
|
+
|
|
667
|
+
for (let i = 0; i < lines.length; i++) {
|
|
668
|
+
const lineY = y + i
|
|
669
|
+
if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) {
|
|
670
|
+
// Still advance flatOffset past this line
|
|
671
|
+
flatOffset = advanceFlatOffset(text, flatOffset, lines[i]!)
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
674
|
+
if (!buffer.inBounds(0, lineY)) {
|
|
675
|
+
flatOffset = advanceFlatOffset(text, flatOffset, lines[i]!)
|
|
676
|
+
continue
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const line = lines[i]!
|
|
680
|
+
const truncated = truncateToWidth(line, availableWidth)
|
|
681
|
+
if (!truncated) {
|
|
682
|
+
flatOffset = advanceFlatOffset(text, flatOffset, line)
|
|
683
|
+
continue
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Render truncated line character by character with per-segment styles
|
|
687
|
+
let col = x
|
|
688
|
+
const lineStartOffset = flatOffset
|
|
689
|
+
let lineCharIdx = 0
|
|
690
|
+
for (const char of truncated) {
|
|
691
|
+
if (col >= maxCol) break
|
|
692
|
+
const srcIdx = lineStartOffset + lineCharIdx
|
|
693
|
+
const segIdx = srcIdx < segmentForChar.length ? segmentForChar[srcIdx]! : 0
|
|
694
|
+
const style = segments[segIdx]!.style
|
|
695
|
+
const charWidth = displayWidth(char)
|
|
696
|
+
if (col + charWidth <= maxCol) {
|
|
697
|
+
buffer.drawChar(col, lineY, char, style)
|
|
698
|
+
// Mark continuation cells for wide characters
|
|
699
|
+
for (let w = 1; w < charWidth; w++) {
|
|
700
|
+
if (buffer.inBounds(col + w, lineY)) {
|
|
701
|
+
buffer.drawChar(col + w, lineY, "", style)
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
col += charWidth
|
|
706
|
+
lineCharIdx += char.length
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
flatOffset = advanceFlatOffset(text, flatOffset, line)
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* Advance the flat text offset past a formatted line.
|
|
715
|
+
* formatTextLines may split on whitespace or add ellipsis — we need to find
|
|
716
|
+
* where this line's content came from in the original flat text.
|
|
717
|
+
*/
|
|
718
|
+
function advanceFlatOffset(flatText: string, offset: number, line: string): number {
|
|
719
|
+
// Skip leading whitespace that formatTextLines may have trimmed
|
|
720
|
+
while (offset < flatText.length && (flatText[offset] === " " || flatText[offset] === "\n")) {
|
|
721
|
+
// Check if the line starts with this whitespace — if so, don't skip it
|
|
722
|
+
if (line.length > 0 && line[0] === flatText[offset]) break
|
|
723
|
+
offset++
|
|
724
|
+
}
|
|
725
|
+
// Advance past the line's characters in the flat text
|
|
726
|
+
let lineIdx = 0
|
|
727
|
+
while (lineIdx < line.length && offset < flatText.length) {
|
|
728
|
+
// Handle ellipsis in truncated text — the ellipsis char isn't in the source
|
|
729
|
+
if (line[lineIdx] === "\u2026") {
|
|
730
|
+
lineIdx++
|
|
731
|
+
continue
|
|
732
|
+
}
|
|
733
|
+
if (line[lineIdx] === flatText[offset]) {
|
|
734
|
+
lineIdx++
|
|
735
|
+
offset++
|
|
736
|
+
} else {
|
|
737
|
+
// Mismatch — skip source char (may have been trimmed by wrapping)
|
|
738
|
+
offset++
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return offset
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Truncate text to fit within a given display width.
|
|
746
|
+
* Respects multi-column characters (CJK, emoji).
|
|
747
|
+
*/
|
|
748
|
+
function truncateToWidth(text: string, maxWidth: number): string {
|
|
749
|
+
if (maxWidth <= 0) return ""
|
|
750
|
+
const textWidth = displayWidth(text)
|
|
751
|
+
if (textWidth <= maxWidth) return text
|
|
752
|
+
|
|
753
|
+
// Need to truncate — iterate character by character
|
|
754
|
+
let width = 0
|
|
755
|
+
let end = 0
|
|
756
|
+
for (const char of text) {
|
|
757
|
+
const charWidth = displayWidth(char)
|
|
758
|
+
if (width + charWidth > maxWidth) break
|
|
759
|
+
width += charWidth
|
|
760
|
+
end += char.length
|
|
761
|
+
}
|
|
762
|
+
return text.slice(0, end)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Collect text content from a node and its children (flat string, no styles).
|
|
767
|
+
* Used by external callers that only need the plain text.
|
|
768
|
+
*/
|
|
769
|
+
function collectTextContent(node: TeaNode): string {
|
|
770
|
+
// Raw text nodes have textContent set directly
|
|
771
|
+
if (node.isRawText && node.textContent !== undefined) {
|
|
772
|
+
return node.textContent
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
let result = ""
|
|
776
|
+
for (const child of node.children) {
|
|
777
|
+
const childProps = child.props as TextProps & BoxProps
|
|
778
|
+
// Skip display="none" children
|
|
779
|
+
if (childProps?.display === "none") continue
|
|
780
|
+
// Skip hidden children (Suspense)
|
|
781
|
+
if (child.hidden) continue
|
|
782
|
+
result += collectTextContent(child)
|
|
783
|
+
}
|
|
784
|
+
return result
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ============================================================================
|
|
788
|
+
// Scroll Indicators
|
|
789
|
+
// ============================================================================
|
|
790
|
+
|
|
791
|
+
interface ScrollState {
|
|
792
|
+
offset: number
|
|
793
|
+
contentHeight: number
|
|
794
|
+
viewportHeight: number
|
|
795
|
+
firstVisibleChild: number
|
|
796
|
+
lastVisibleChild: number
|
|
797
|
+
stickyChildren?: Array<{
|
|
798
|
+
index: number
|
|
799
|
+
naturalTop: number
|
|
800
|
+
renderOffset: number
|
|
801
|
+
}>
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Render scroll indicators for a scrollable container.
|
|
806
|
+
*/
|
|
807
|
+
function renderScrollIndicators(
|
|
808
|
+
_node: TeaNode,
|
|
809
|
+
buffer: RenderBuffer,
|
|
810
|
+
layout: Rect,
|
|
811
|
+
props: BoxProps,
|
|
812
|
+
scrollState: ScrollState,
|
|
813
|
+
): void {
|
|
814
|
+
const { x, width, height } = layout
|
|
815
|
+
const y = layout.y
|
|
816
|
+
|
|
817
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, right: 0 }
|
|
818
|
+
const canScrollUp = scrollState.offset > 0
|
|
819
|
+
const canScrollDown = scrollState.offset + scrollState.viewportHeight < scrollState.contentHeight
|
|
820
|
+
|
|
821
|
+
const indicatorX = x + width - border.right - 1
|
|
822
|
+
const style: RenderStyle = { fg: props.borderColor ?? "#808080" }
|
|
823
|
+
|
|
824
|
+
// Up indicator
|
|
825
|
+
if (canScrollUp) {
|
|
826
|
+
const indicatorY = y + border.top
|
|
827
|
+
if (buffer.inBounds(indicatorX, indicatorY)) {
|
|
828
|
+
buffer.drawChar(indicatorX, indicatorY, "▲", style)
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// Down indicator
|
|
833
|
+
if (canScrollDown) {
|
|
834
|
+
const indicatorY = y + height - border.bottom - 1
|
|
835
|
+
if (buffer.inBounds(indicatorX, indicatorY)) {
|
|
836
|
+
buffer.drawChar(indicatorX, indicatorY, "▼", style)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ============================================================================
|
|
842
|
+
// Children Rendering
|
|
843
|
+
// ============================================================================
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* Render children of a scroll container.
|
|
847
|
+
*/
|
|
848
|
+
function renderScrollContainerChildren(
|
|
849
|
+
node: TeaNode,
|
|
850
|
+
buffer: RenderBuffer,
|
|
851
|
+
props: BoxProps,
|
|
852
|
+
clipBounds?: ClipRect,
|
|
853
|
+
): void {
|
|
854
|
+
const layout = node.contentRect
|
|
855
|
+
const ss = node.scrollState as ScrollState | undefined
|
|
856
|
+
if (!layout || !ss) return
|
|
857
|
+
|
|
858
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
|
|
859
|
+
const padding = getPadding(props)
|
|
860
|
+
|
|
861
|
+
const nodeClip: ClipRect = {
|
|
862
|
+
top: layout.y + border.top + padding.top,
|
|
863
|
+
bottom: layout.y + layout.height - border.bottom - padding.bottom,
|
|
864
|
+
left: layout.x + border.left + padding.left,
|
|
865
|
+
right: layout.x + layout.width - border.right - padding.right,
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const childClipBounds: ClipRect = clipBounds
|
|
869
|
+
? {
|
|
870
|
+
top: Math.max(clipBounds.top, nodeClip.top),
|
|
871
|
+
bottom: Math.min(clipBounds.bottom, nodeClip.bottom),
|
|
872
|
+
left: Math.max(clipBounds.left ?? nodeClip.left!, nodeClip.left!),
|
|
873
|
+
right: Math.min(clipBounds.right ?? nodeClip.right!, nodeClip.right!),
|
|
874
|
+
}
|
|
875
|
+
: nodeClip
|
|
876
|
+
|
|
877
|
+
// Render visible children
|
|
878
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
879
|
+
const child = node.children[i]
|
|
880
|
+
if (!child) continue
|
|
881
|
+
const childProps = child.props as BoxProps
|
|
882
|
+
|
|
883
|
+
if (childProps.position === "sticky") continue
|
|
884
|
+
if (i < ss.firstVisibleChild || i > ss.lastVisibleChild) continue
|
|
885
|
+
|
|
886
|
+
renderNodeToBuffer(child, buffer, ss.offset, childClipBounds)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// Render sticky children
|
|
890
|
+
if (ss.stickyChildren) {
|
|
891
|
+
for (const sticky of ss.stickyChildren) {
|
|
892
|
+
const child = node.children[sticky.index]
|
|
893
|
+
if (!child?.contentRect) continue
|
|
894
|
+
|
|
895
|
+
const stickyScrollOffset = sticky.naturalTop - sticky.renderOffset
|
|
896
|
+
renderNodeToBuffer(child, buffer, stickyScrollOffset, childClipBounds)
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Render children of a normal container.
|
|
903
|
+
*/
|
|
904
|
+
function renderNormalChildren(
|
|
905
|
+
node: TeaNode,
|
|
906
|
+
buffer: RenderBuffer,
|
|
907
|
+
scrollOffset: number,
|
|
908
|
+
props: BoxProps,
|
|
909
|
+
clipBounds?: ClipRect,
|
|
910
|
+
): void {
|
|
911
|
+
const layout = node.contentRect
|
|
912
|
+
if (!layout) return
|
|
913
|
+
|
|
914
|
+
let effectiveClipBounds = clipBounds
|
|
915
|
+
|
|
916
|
+
if (props.overflow === "hidden") {
|
|
917
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
|
|
918
|
+
const padding = getPadding(props)
|
|
919
|
+
|
|
920
|
+
// Adjust layout position by scrollOffset to get screen coordinates
|
|
921
|
+
const adjustedY = layout.y - scrollOffset
|
|
922
|
+
const nodeClip: ClipRect = {
|
|
923
|
+
top: adjustedY + border.top + padding.top,
|
|
924
|
+
bottom: adjustedY + layout.height - border.bottom - padding.bottom,
|
|
925
|
+
left: layout.x + border.left + padding.left,
|
|
926
|
+
right: layout.x + layout.width - border.right - padding.right,
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
effectiveClipBounds = clipBounds
|
|
930
|
+
? {
|
|
931
|
+
top: Math.max(clipBounds.top, nodeClip.top),
|
|
932
|
+
bottom: Math.min(clipBounds.bottom, nodeClip.bottom),
|
|
933
|
+
left: Math.max(clipBounds.left ?? nodeClip.left!, nodeClip.left!),
|
|
934
|
+
right: Math.min(clipBounds.right ?? nodeClip.right!, nodeClip.right!),
|
|
935
|
+
}
|
|
936
|
+
: nodeClip
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
const hasStickyChildren = !!(node.stickyChildren && node.stickyChildren.length > 0)
|
|
940
|
+
|
|
941
|
+
// Multi-pass rendering to match CSS paint order (and content-phase.ts):
|
|
942
|
+
// 1. Normal-flow children (skip sticky and absolute)
|
|
943
|
+
// 2. Sticky children at computed positions
|
|
944
|
+
// 3. Absolute children on top of everything
|
|
945
|
+
|
|
946
|
+
// First pass: render normal-flow children (skip sticky + absolute)
|
|
947
|
+
let hasAbsoluteChildren = false
|
|
948
|
+
for (const child of node.children) {
|
|
949
|
+
const childProps = child.props as BoxProps
|
|
950
|
+
if (childProps.position === "absolute") {
|
|
951
|
+
hasAbsoluteChildren = true
|
|
952
|
+
continue // Skip — rendered in third pass
|
|
953
|
+
}
|
|
954
|
+
if (hasStickyChildren && childProps.position === "sticky") continue
|
|
955
|
+
renderNodeToBuffer(child, buffer, scrollOffset, effectiveClipBounds)
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// Second pass: render sticky children at their computed positions
|
|
959
|
+
if (node.stickyChildren) {
|
|
960
|
+
for (const sticky of node.stickyChildren) {
|
|
961
|
+
const child = node.children[sticky.index]
|
|
962
|
+
if (!child?.contentRect) continue
|
|
963
|
+
const stickyScrollOffset = sticky.naturalTop - sticky.renderOffset
|
|
964
|
+
renderNodeToBuffer(child, buffer, stickyScrollOffset, effectiveClipBounds)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
// Third pass: render absolute children on top (CSS paint order)
|
|
969
|
+
if (hasAbsoluteChildren) {
|
|
970
|
+
for (const child of node.children) {
|
|
971
|
+
const childProps = child.props as BoxProps
|
|
972
|
+
if (childProps.position !== "absolute") continue
|
|
973
|
+
renderNodeToBuffer(child, buffer, scrollOffset, effectiveClipBounds)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|