@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.
Files changed (92) hide show
  1. package/package.json +54 -0
  2. package/src/adapters/canvas-adapter.ts +356 -0
  3. package/src/adapters/dom-adapter.ts +452 -0
  4. package/src/adapters/flexily-zero-adapter.ts +368 -0
  5. package/src/adapters/terminal-adapter.ts +305 -0
  6. package/src/adapters/yoga-adapter.ts +370 -0
  7. package/src/ansi/ansi.ts +251 -0
  8. package/src/ansi/constants.ts +76 -0
  9. package/src/ansi/detection.ts +441 -0
  10. package/src/ansi/hyperlink.ts +38 -0
  11. package/src/ansi/index.ts +201 -0
  12. package/src/ansi/patch-console.ts +159 -0
  13. package/src/ansi/sgr-codes.ts +34 -0
  14. package/src/ansi/storybook.ts +209 -0
  15. package/src/ansi/term.ts +724 -0
  16. package/src/ansi/types.ts +202 -0
  17. package/src/ansi/underline.ts +156 -0
  18. package/src/ansi/utils.ts +65 -0
  19. package/src/ansi-sanitize.ts +509 -0
  20. package/src/app.ts +571 -0
  21. package/src/bound-term.ts +94 -0
  22. package/src/bracketed-paste.ts +75 -0
  23. package/src/browser-renderer.ts +174 -0
  24. package/src/buffer.ts +1984 -0
  25. package/src/clipboard.ts +74 -0
  26. package/src/cursor-query.ts +85 -0
  27. package/src/device-attrs.ts +228 -0
  28. package/src/devtools.ts +123 -0
  29. package/src/dom/index.ts +194 -0
  30. package/src/errors.ts +39 -0
  31. package/src/focus-reporting.ts +48 -0
  32. package/src/hit-registry-core.ts +228 -0
  33. package/src/hit-registry.ts +176 -0
  34. package/src/index.ts +458 -0
  35. package/src/input.ts +119 -0
  36. package/src/inspector.ts +155 -0
  37. package/src/kitty-detect.ts +95 -0
  38. package/src/kitty-manager.ts +160 -0
  39. package/src/layout-engine.ts +296 -0
  40. package/src/layout.ts +26 -0
  41. package/src/measurer.ts +74 -0
  42. package/src/mode-query.ts +106 -0
  43. package/src/mouse-events.ts +419 -0
  44. package/src/mouse.ts +83 -0
  45. package/src/non-tty.ts +223 -0
  46. package/src/osc-markers.ts +32 -0
  47. package/src/osc-palette.ts +169 -0
  48. package/src/output.ts +406 -0
  49. package/src/pane-manager.ts +248 -0
  50. package/src/pipeline/CLAUDE.md +587 -0
  51. package/src/pipeline/content-phase-adapter.ts +976 -0
  52. package/src/pipeline/content-phase.ts +1765 -0
  53. package/src/pipeline/helpers.ts +42 -0
  54. package/src/pipeline/index.ts +416 -0
  55. package/src/pipeline/layout-phase.ts +686 -0
  56. package/src/pipeline/measure-phase.ts +198 -0
  57. package/src/pipeline/measure-stats.ts +21 -0
  58. package/src/pipeline/output-phase.ts +2593 -0
  59. package/src/pipeline/render-box.ts +343 -0
  60. package/src/pipeline/render-helpers.ts +243 -0
  61. package/src/pipeline/render-text.ts +1255 -0
  62. package/src/pipeline/types.ts +161 -0
  63. package/src/pipeline.ts +29 -0
  64. package/src/pixel-size.ts +119 -0
  65. package/src/render-adapter.ts +179 -0
  66. package/src/renderer.ts +1330 -0
  67. package/src/runtime/create-app.tsx +1845 -0
  68. package/src/runtime/create-buffer.ts +18 -0
  69. package/src/runtime/create-runtime.ts +325 -0
  70. package/src/runtime/diff.ts +56 -0
  71. package/src/runtime/event-handlers.ts +254 -0
  72. package/src/runtime/index.ts +119 -0
  73. package/src/runtime/keys.ts +8 -0
  74. package/src/runtime/layout.ts +164 -0
  75. package/src/runtime/run.tsx +318 -0
  76. package/src/runtime/term-provider.ts +399 -0
  77. package/src/runtime/terminal-lifecycle.ts +246 -0
  78. package/src/runtime/tick.ts +219 -0
  79. package/src/runtime/types.ts +210 -0
  80. package/src/scheduler.ts +723 -0
  81. package/src/screenshot.ts +57 -0
  82. package/src/scroll-region.ts +69 -0
  83. package/src/scroll-utils.ts +97 -0
  84. package/src/term-def.ts +267 -0
  85. package/src/terminal-caps.ts +5 -0
  86. package/src/terminal-colors.ts +216 -0
  87. package/src/termtest.ts +224 -0
  88. package/src/text-sizing.ts +109 -0
  89. package/src/toolbelt/index.ts +72 -0
  90. package/src/unicode.ts +1763 -0
  91. package/src/xterm/index.ts +491 -0
  92. 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
+ }