@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,686 @@
1
+ /**
2
+ * Phase 2: Layout Phase
3
+ *
4
+ * Run Yoga layout calculation and propagate dimensions to all nodes.
5
+ */
6
+
7
+ import { createLogger } from "loggily"
8
+ import { measureStats } from "./measure-stats"
9
+ import { type BoxProps, type TeaNode, type Rect, rectEqual } from "@silvery/tea/types"
10
+ import { getBorderSize, getPadding } from "./helpers"
11
+
12
+ const log = createLogger("silvery:layout")
13
+
14
+ /**
15
+ * Run Yoga layout calculation and propagate dimensions to all nodes.
16
+ *
17
+ * @param root The root SilveryNode
18
+ * @param width Terminal width in columns
19
+ * @param height Terminal height in rows
20
+ */
21
+ export function layoutPhase(root: TeaNode, width: number, height: number): void {
22
+ // Check if dimensions changed from previous layout
23
+ const prevLayout = root.contentRect
24
+ const dimensionsChanged = prevLayout && (prevLayout.width !== width || prevLayout.height !== height)
25
+
26
+ // Only recalculate if something changed (dirty nodes or dimensions)
27
+ if (!dimensionsChanged && !hasLayoutDirtyNodes(root)) {
28
+ return
29
+ }
30
+
31
+ // Run layout calculation (root always has a layoutNode)
32
+ if (root.layoutNode) {
33
+ const nodeCount = countNodes(root)
34
+ measureStats.reset()
35
+ const t0 = Date.now()
36
+ root.layoutNode.calculateLayout(width, height)
37
+ const elapsed = Date.now() - t0
38
+ log.debug?.(
39
+ `calculateLayout: ${elapsed}ms (${nodeCount} nodes) measure: calls=${measureStats.calls} hits=${measureStats.cacheHits} collects=${measureStats.textCollects} displayWidth=${measureStats.displayWidthCalls}`,
40
+ )
41
+ }
42
+
43
+ // Propagate computed dimensions to all nodes
44
+ propagateLayout(root, 0, 0)
45
+
46
+ // NOTE: Subscribers are NOT notified here anymore.
47
+ // They are notified in executeRender AFTER screenRectPhase completes,
48
+ // so useScreenRectCallback can read the correct screen positions.
49
+ }
50
+
51
+ /**
52
+ * Count total nodes in tree.
53
+ */
54
+ function countNodes(node: TeaNode): number {
55
+ let count = 1
56
+ for (const child of node.children) {
57
+ count += countNodes(child)
58
+ }
59
+ return count
60
+ }
61
+
62
+ /**
63
+ * Check if any node in the tree has layoutDirty flag set.
64
+ */
65
+ function hasLayoutDirtyNodes(node: TeaNode, path = "root"): boolean {
66
+ if (node.layoutDirty) {
67
+ const props = node.props as BoxProps
68
+ log.debug?.(`dirty node found: ${path} (id=${props.id ?? "?"}, type=${node.type})`)
69
+ return true
70
+ }
71
+ for (let i = 0; i < node.children.length; i++) {
72
+ if (hasLayoutDirtyNodes(node.children[i]!, `${path}[${i}]`)) return true
73
+ }
74
+ return false
75
+ }
76
+
77
+ /**
78
+ * Propagate computed layout from Yoga nodes to SilveryNodes.
79
+ * Sets contentRect (content-relative position) on each node.
80
+ *
81
+ * @param node The node to process
82
+ * @param parentX Absolute X position of parent
83
+ * @param parentY Absolute Y position of parent
84
+ */
85
+ function propagateLayout(node: TeaNode, parentX: number, parentY: number): void {
86
+ // Save previous layout for change detection
87
+ node.prevLayout = node.contentRect
88
+
89
+ // Virtual/raw text nodes (no layoutNode) inherit parent's position
90
+ if (!node.layoutNode) {
91
+ const rect: Rect = {
92
+ x: parentX,
93
+ y: parentY,
94
+ width: 0,
95
+ height: 0,
96
+ }
97
+ node.contentRect = rect
98
+ node.layoutDirty = false
99
+ // Still recurse to children (virtual text nodes can have raw text children)
100
+ for (const child of node.children) {
101
+ propagateLayout(child, parentX, parentY)
102
+ }
103
+ return
104
+ }
105
+
106
+ // Compute absolute position from Yoga (content-relative)
107
+ const rect: Rect = {
108
+ x: parentX + node.layoutNode.getComputedLeft(),
109
+ y: parentY + node.layoutNode.getComputedTop(),
110
+ width: node.layoutNode.getComputedWidth(),
111
+ height: node.layoutNode.getComputedHeight(),
112
+ }
113
+ node.contentRect = rect
114
+
115
+ // Clear layout dirty flag
116
+ node.layoutDirty = false
117
+
118
+ // Set authoritative "layout changed this frame" flag.
119
+ // Unlike !rectEqual(prevLayout, contentRect) which becomes stale when
120
+ // layout phase skips on subsequent frames, this flag is explicitly set
121
+ // each time propagateLayout runs and cleared by the content phase.
122
+ node.layoutChangedThisFrame = !!(node.prevLayout && !rectEqual(node.prevLayout, node.contentRect))
123
+
124
+ // STRICT invariant: if layoutChangedThisFrame is true, prevLayout must differ from contentRect.
125
+ // This validates that the flag is consistent with the actual rect comparison. A violation
126
+ // would mean the flag is set spuriously, causing unnecessary re-renders and cascade propagation.
127
+ if (process.env.SILVERY_STRICT && node.layoutChangedThisFrame) {
128
+ if (rectEqual(node.prevLayout, node.contentRect)) {
129
+ const props = node.props as BoxProps
130
+ throw new Error(
131
+ `[SILVERY_STRICT] layoutChangedThisFrame=true but prevLayout equals contentRect ` +
132
+ `(node: ${props.id ?? node.type}, rect: ${JSON.stringify(node.contentRect)})`,
133
+ )
134
+ }
135
+ }
136
+
137
+ // When layout changes, mark ancestors subtreeDirty so contentPhase doesn't
138
+ // fast-path skip them. Without this, a deeply nested node whose dimensions
139
+ // change (e.g., width 3→4) would never be re-rendered because all ancestors
140
+ // appear clean — their own layout didn't change, just a descendant's did.
141
+ if (node.layoutChangedThisFrame) {
142
+ let ancestor = node.parent
143
+ while (ancestor && !ancestor.subtreeDirty) {
144
+ ancestor.subtreeDirty = true
145
+ ancestor = ancestor.parent
146
+ }
147
+ }
148
+
149
+ // Recurse to children
150
+ for (const child of node.children) {
151
+ propagateLayout(child, rect.x, rect.y)
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Notify all layout subscribers of dimension changes.
157
+ *
158
+ * Called from executeRender AFTER screenRectPhase completes,
159
+ * so useScreenRectCallback can read correct screen positions.
160
+ *
161
+ * Notifies when EITHER contentRect, screenRect, or renderRect changed.
162
+ * screenRect can change from scroll offset changes even when
163
+ * contentRect stays the same — subscribers (like useScreenRectCallback)
164
+ * need notification in both cases. renderRect can change from sticky
165
+ * offset changes even when screenRect stays the same.
166
+ */
167
+ export function notifyLayoutSubscribers(node: TeaNode): void {
168
+ // Notify if content rect, screen rect, or render rect changed
169
+ const contentChanged = !rectEqual(node.prevLayout, node.contentRect)
170
+ const screenChanged = !rectEqual(node.prevScreenRect, node.screenRect)
171
+ const renderChanged = !rectEqual(node.prevRenderRect, node.renderRect)
172
+ if (contentChanged || screenChanged || renderChanged) {
173
+ for (const subscriber of node.layoutSubscribers) {
174
+ subscriber()
175
+ }
176
+ }
177
+
178
+ // Recurse to children
179
+ for (const child of node.children) {
180
+ notifyLayoutSubscribers(child)
181
+ }
182
+ }
183
+
184
+ // Re-export from types
185
+ export { rectEqual } from "@silvery/tea/types"
186
+
187
+ // ============================================================================
188
+ // Phase 2.5: Scroll Phase (for overflow='scroll' containers)
189
+ // ============================================================================
190
+
191
+ /**
192
+ * Options for scrollPhase.
193
+ */
194
+ export interface ScrollPhaseOptions {
195
+ /**
196
+ * Skip state updates (for fresh render comparisons).
197
+ * When true, calculates scroll positions but doesn't mutate node.scrollState.
198
+ * Default: false
199
+ */
200
+ skipStateUpdates?: boolean
201
+ }
202
+
203
+ /**
204
+ * Calculate scroll state for all overflow='scroll' containers.
205
+ *
206
+ * This phase runs after layout to determine which children are visible
207
+ * within each scrollable container.
208
+ */
209
+ export function scrollPhase(root: TeaNode, options: ScrollPhaseOptions = {}): void {
210
+ const { skipStateUpdates = false } = options
211
+ traverseTree(root, (node) => {
212
+ const props = node.props as BoxProps
213
+ if (props.overflow !== "scroll") return
214
+
215
+ // Calculate scroll state for this container
216
+ calculateScrollState(node, props, skipStateUpdates)
217
+ })
218
+ }
219
+
220
+ /**
221
+ * Calculate scroll state for a single scrollable container.
222
+ */
223
+ function calculateScrollState(node: TeaNode, props: BoxProps, skipStateUpdates: boolean): void {
224
+ const layout = node.contentRect
225
+ if (!layout || !node.layoutNode) return
226
+
227
+ // Calculate viewport (container minus borders/padding)
228
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
229
+ const padding = getPadding(props)
230
+
231
+ const rawViewportHeight = layout.height - border.top - border.bottom - padding.top - padding.bottom
232
+
233
+ // Calculate total content height and child positions
234
+ let contentHeight = 0
235
+ const childPositions: {
236
+ child: TeaNode
237
+ top: number
238
+ bottom: number
239
+ index: number
240
+ isSticky: boolean
241
+ stickyTop?: number
242
+ stickyBottom?: number
243
+ }[] = []
244
+
245
+ for (let i = 0; i < node.children.length; i++) {
246
+ const child = node.children[i]!
247
+ if (!child.layoutNode || !child.contentRect) continue
248
+
249
+ const childTop = child.contentRect.y - layout.y - border.top - padding.top
250
+ const childBottom = childTop + child.contentRect.height
251
+ const childProps = child.props as BoxProps
252
+
253
+ childPositions.push({
254
+ child: child!,
255
+ top: childTop,
256
+ bottom: childBottom,
257
+ index: i,
258
+ isSticky: childProps.position === "sticky",
259
+ stickyTop: childProps.stickyTop,
260
+ stickyBottom: childProps.stickyBottom,
261
+ })
262
+
263
+ contentHeight = Math.max(contentHeight, childBottom)
264
+ }
265
+
266
+ const viewportHeight = rawViewportHeight
267
+
268
+ // Reserve 1 row at the bottom for the overflow indicator when:
269
+ // 1. Container uses borderless overflow indicators (overflowIndicator prop)
270
+ // 2. Content exceeds viewport (there will be hidden items below or above)
271
+ // This ensures the indicator doesn't overlay the last visible child's content.
272
+ const showBorderlessIndicator = props.overflowIndicator === true && !props.borderStyle
273
+ const hasOverflow = contentHeight > rawViewportHeight
274
+ const indicatorReserve = showBorderlessIndicator && hasOverflow ? 1 : 0
275
+
276
+ // Calculate scroll offset based on scrollTo prop
277
+ // Use "ensure visible" scrolling: only scroll when target would be off-screen
278
+ // Preserve previous offset when target is already visible
279
+ //
280
+ // Priority:
281
+ // 1. If scrollTo is defined: use edge-based scrolling to ensure child is visible
282
+ // 2. If scrollOffset is defined: use explicit offset (for frozen scroll state)
283
+ // 3. Otherwise: use previous offset or default to 0
284
+ const prevOffset = node.scrollState?.offset
285
+ const explicitOffset = props.scrollOffset
286
+ let scrollOffset = explicitOffset ?? prevOffset ?? 0
287
+ const scrollTo = props.scrollTo
288
+
289
+ if (scrollTo !== undefined && scrollTo >= 0 && scrollTo < childPositions.length) {
290
+ // Find the target child
291
+ const target = childPositions.find((c) => c.index === scrollTo)
292
+ if (target) {
293
+ // Calculate current visible range, accounting for indicator reserve.
294
+ // The effective visible height is reduced by indicatorReserve so the
295
+ // scrollTo target is fully visible ABOVE the overflow indicator row.
296
+ const effectiveHeight = viewportHeight - indicatorReserve
297
+ const visibleTop = scrollOffset
298
+ const visibleBottom = scrollOffset + effectiveHeight
299
+
300
+ // Only scroll if target is outside visible range
301
+ if (target.top < visibleTop) {
302
+ // Target is above viewport - scroll up to show it at top
303
+ scrollOffset = target.top
304
+ } else if (target.bottom > visibleBottom) {
305
+ // Target is below viewport - scroll down to show it at bottom
306
+ scrollOffset = target.bottom - effectiveHeight
307
+ }
308
+ // Otherwise, keep current scroll position (target is visible)
309
+
310
+ // Clamp to valid range
311
+ scrollOffset = Math.max(0, scrollOffset)
312
+ scrollOffset = Math.min(scrollOffset, Math.max(0, contentHeight - viewportHeight))
313
+ }
314
+ }
315
+
316
+ // Determine visible children.
317
+ // When the overflow indicator reserves a row (indicatorReserve=1), reduce the
318
+ // visible bottom by 1 so the indicator has its own row after the last visible child.
319
+ const visibleTop = scrollOffset
320
+ const visibleBottom = scrollOffset + viewportHeight - indicatorReserve
321
+
322
+ let firstVisible = -1
323
+ let lastVisible = -1
324
+ let hiddenAbove = 0
325
+ let hiddenBelow = 0
326
+
327
+ for (const cp of childPositions) {
328
+ // Sticky children are always considered "visible" for rendering purposes
329
+ if (cp.isSticky) {
330
+ if (firstVisible === -1) firstVisible = cp.index
331
+ lastVisible = Math.max(lastVisible, cp.index)
332
+ continue
333
+ }
334
+
335
+ // Skip zero-height children from hidden counts — they have no visual
336
+ // presence and would produce spurious overflow indicators (e.g., a
337
+ // zero-height child at position 0 has top=0, bottom=0, and 0 <= 0
338
+ // would incorrectly count it as "hidden above").
339
+ if (cp.top === cp.bottom) {
340
+ continue
341
+ }
342
+
343
+ if (cp.bottom <= visibleTop) {
344
+ hiddenAbove++
345
+ } else if (cp.top >= visibleBottom) {
346
+ hiddenBelow++
347
+ } else if (cp.top < visibleTop) {
348
+ // Child is partially visible at top — render it (clipped by scroll
349
+ // container's clip bounds) so partial content is visible instead of blank space
350
+ if (firstVisible === -1) firstVisible = cp.index
351
+ lastVisible = Math.max(lastVisible, cp.index)
352
+ } else if (cp.bottom > visibleBottom) {
353
+ // Child is partially visible at bottom — render it (clipped by scroll
354
+ // container's clip bounds) so partial content is visible instead of blank space.
355
+ // When indicatorReserve is active, this child extends past the reserved row,
356
+ // but we still render it — the overflow indicator renders AFTER children and
357
+ // overlays the appropriate row.
358
+ if (firstVisible === -1) firstVisible = cp.index
359
+ lastVisible = cp.index
360
+ // When indicator reserve is active, count partially visible bottom children
361
+ // in hiddenBelow so the indicator shows the correct count.
362
+ if (indicatorReserve > 0) {
363
+ hiddenBelow++
364
+ }
365
+ } else {
366
+ // This child is fully visible within the viewport
367
+ if (firstVisible === -1) firstVisible = cp.index
368
+ lastVisible = cp.index
369
+ }
370
+ }
371
+
372
+ // Calculate sticky children render positions
373
+ const stickyChildren: NonNullable<TeaNode["scrollState"]>["stickyChildren"] = []
374
+
375
+ for (const cp of childPositions) {
376
+ if (!cp.isSticky) continue
377
+
378
+ const childHeight = cp.bottom - cp.top
379
+ const stickyTop = cp.stickyTop ?? 0
380
+ const stickyBottom = cp.stickyBottom
381
+
382
+ // Natural position: where it would be without sticking (relative to viewport)
383
+ const naturalRenderY = cp.top - scrollOffset
384
+
385
+ let renderOffset: number
386
+
387
+ if (stickyBottom !== undefined) {
388
+ // Sticky to bottom: element pins to bottom edge when scrolled past
389
+ const bottomPinPosition = viewportHeight - stickyBottom - childHeight
390
+ // Use natural position if it's below the pin point, otherwise pin
391
+ renderOffset = Math.min(naturalRenderY, bottomPinPosition)
392
+ } else if (naturalRenderY >= stickyTop) {
393
+ // Child hasn't reached stick point: use natural position
394
+ renderOffset = naturalRenderY
395
+ } else if (childHeight > viewportHeight) {
396
+ // Oversized sticky-top child scrolled past stick point: progressively
397
+ // scroll the child so its bottom aligns with viewport bottom when
398
+ // scrolled far enough. Clamp between bottom-align and stick point.
399
+ renderOffset = Math.max(viewportHeight - childHeight, naturalRenderY)
400
+ } else {
401
+ // Normal sticky-top child scrolled past stick point: pin at stickyTop
402
+ renderOffset = stickyTop
403
+ }
404
+
405
+ // Clamp to viewport bounds — only when element is actually sticking.
406
+ // Elements at their natural position below the viewport must NOT be
407
+ // pulled up into view by clamping (that would overwrite other children's
408
+ // pixels, corrupting incremental rendering's buffer shift).
409
+ const isSticking = renderOffset !== naturalRenderY
410
+ if (isSticking) {
411
+ if (childHeight > viewportHeight) {
412
+ renderOffset = Math.max(viewportHeight - childHeight, renderOffset)
413
+ } else {
414
+ renderOffset = Math.max(0, Math.min(renderOffset, viewportHeight - childHeight))
415
+ }
416
+ }
417
+
418
+ // Skip off-screen sticky children — they're not visible and shouldn't
419
+ // be rendered (would corrupt other children's pixels in the buffer).
420
+ if (renderOffset + childHeight <= 0 || renderOffset >= viewportHeight) continue
421
+
422
+ stickyChildren.push({
423
+ index: cp.index,
424
+ renderOffset,
425
+ naturalTop: cp.top,
426
+ height: childHeight,
427
+ })
428
+ }
429
+
430
+ // Skip state updates for fresh render comparisons (SILVERY_STRICT)
431
+ if (skipStateUpdates) return
432
+
433
+ // Track previous visible range for incremental rendering
434
+ const prevFirstVisible = node.scrollState?.firstVisibleChild ?? firstVisible
435
+ const prevLastVisible = node.scrollState?.lastVisibleChild ?? lastVisible
436
+
437
+ // Mark node dirty if scroll offset or visible range changed (for incremental rendering)
438
+ // Without this, contentPhase would skip the container and children would
439
+ // remain at their old pixel positions in the cloned buffer
440
+ if (scrollOffset !== prevOffset || firstVisible !== prevFirstVisible || lastVisible !== prevLastVisible) {
441
+ node.subtreeDirty = true
442
+ }
443
+
444
+ // Store scroll state (preserve previous offset and visible range for incremental rendering)
445
+ node.scrollState = {
446
+ offset: scrollOffset,
447
+ prevOffset: prevOffset ?? scrollOffset,
448
+ contentHeight,
449
+ viewportHeight,
450
+ firstVisibleChild: firstVisible,
451
+ lastVisibleChild: lastVisible,
452
+ prevFirstVisibleChild: prevFirstVisible,
453
+ prevLastVisibleChild: prevLastVisible,
454
+ hiddenAbove,
455
+ hiddenBelow,
456
+ stickyChildren: stickyChildren.length > 0 ? stickyChildren : undefined,
457
+ }
458
+ }
459
+
460
+ // ============================================================================
461
+ // Phase 2.55: Sticky Phase (for non-scroll containers with sticky children)
462
+ // ============================================================================
463
+
464
+ /**
465
+ * Compute sticky offsets for non-scroll containers that have sticky children.
466
+ *
467
+ * Scroll containers handle their own sticky logic in calculateScrollState().
468
+ * This phase handles the remaining case: parents that are NOT overflow="scroll"
469
+ * but still contain position="sticky" children with stickyBottom.
470
+ *
471
+ * For non-scroll containers, sticky means: pin the child to the parent's bottom
472
+ * edge when content is shorter than the parent. When content fills the parent,
473
+ * the child stays at its natural position.
474
+ */
475
+ export function stickyPhase(root: TeaNode): void {
476
+ traverseTree(root, (node) => {
477
+ const props = node.props as BoxProps
478
+ // Skip scroll containers — they handle sticky in scrollPhase
479
+ if (props.overflow === "scroll") return
480
+
481
+ // Check if any children are sticky with stickyBottom
482
+ let hasStickyChildren = false
483
+ for (const child of node.children) {
484
+ const childProps = child.props as BoxProps
485
+ if (childProps.position === "sticky" && childProps.stickyBottom !== undefined) {
486
+ hasStickyChildren = true
487
+ break
488
+ }
489
+ }
490
+
491
+ if (!hasStickyChildren) {
492
+ // Clear stale data if previously had sticky children
493
+ if (node.stickyChildren !== undefined) {
494
+ node.stickyChildren = undefined
495
+ node.subtreeDirty = true
496
+ }
497
+ return
498
+ }
499
+
500
+ const layout = node.contentRect
501
+ if (!layout || !node.layoutNode) return
502
+
503
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
504
+ const padding = getPadding(props)
505
+ const parentContentHeight = layout.height - border.top - border.bottom - padding.top - padding.bottom
506
+
507
+ const newStickyChildren: NonNullable<TeaNode["stickyChildren"]> = []
508
+
509
+ for (let i = 0; i < node.children.length; i++) {
510
+ const child = node.children[i]!
511
+ const childProps = child.props as BoxProps
512
+ if (childProps.position !== "sticky") continue
513
+ if (childProps.stickyBottom === undefined) continue
514
+
515
+ if (!child.contentRect) continue
516
+
517
+ // Natural position relative to parent content area
518
+ const naturalY = child.contentRect.y - layout.y - border.top - padding.top
519
+ const childHeight = child.contentRect.height
520
+ const stickyBottom = childProps.stickyBottom
521
+
522
+ // Pin position: where the child would be if pinned to parent bottom
523
+ const bottomPin = parentContentHeight - stickyBottom - childHeight
524
+ // Child pins to bottom when content is short (naturalY < bottomPin)
525
+ // Stays at natural position when content fills parent (naturalY >= bottomPin)
526
+ const renderOffset = Math.max(naturalY, bottomPin)
527
+
528
+ newStickyChildren.push({
529
+ index: i,
530
+ renderOffset,
531
+ naturalTop: naturalY,
532
+ height: childHeight,
533
+ })
534
+ }
535
+
536
+ // Compare with previous value to detect changes
537
+ const prev = node.stickyChildren
538
+ const next = newStickyChildren.length > 0 ? newStickyChildren : undefined
539
+
540
+ const changed = !stickyChildrenEqual(prev, next)
541
+ node.stickyChildren = next
542
+
543
+ if (changed) {
544
+ node.subtreeDirty = true
545
+ }
546
+ })
547
+ }
548
+
549
+ /**
550
+ * Compare two stickyChildren arrays for equality.
551
+ */
552
+ function stickyChildrenEqual(a: TeaNode["stickyChildren"], b: TeaNode["stickyChildren"]): boolean {
553
+ if (a === b) return true
554
+ if (!a || !b) return false
555
+ if (a.length !== b.length) return false
556
+ for (let i = 0; i < a.length; i++) {
557
+ const ai = a[i]!
558
+ const bi = b[i]!
559
+ if (
560
+ ai.index !== bi.index ||
561
+ ai.renderOffset !== bi.renderOffset ||
562
+ ai.naturalTop !== bi.naturalTop ||
563
+ ai.height !== bi.height
564
+ ) {
565
+ return false
566
+ }
567
+ }
568
+ return true
569
+ }
570
+
571
+ /**
572
+ * Traverse tree in depth-first order.
573
+ */
574
+ function traverseTree(node: TeaNode, callback: (node: TeaNode) => void): void {
575
+ callback(node)
576
+ for (const child of node.children) {
577
+ traverseTree(child, callback)
578
+ }
579
+ }
580
+
581
+ // ============================================================================
582
+ // Phase 2.6: Screen Rect Phase
583
+ // ============================================================================
584
+
585
+ /**
586
+ * Calculate screen-relative positions for all nodes.
587
+ *
588
+ * This phase runs after scroll phase to compute where each node actually
589
+ * appears on the terminal screen, accounting for all ancestor scroll offsets.
590
+ *
591
+ * Also computes `renderRect` which accounts for sticky render offsets.
592
+ * For non-sticky nodes, renderRect === screenRect. For sticky nodes,
593
+ * renderRect reflects the actual pixel position where the node is painted.
594
+ *
595
+ * Screen position = content position - sum of ancestor scroll offsets
596
+ */
597
+ export function screenRectPhase(root: TeaNode): void {
598
+ propagateScreenRect(root, 0)
599
+ }
600
+
601
+ /**
602
+ * Propagate screen-relative positions through the tree.
603
+ *
604
+ * @param node The node to process
605
+ * @param ancestorScrollOffset Sum of all ancestor scroll offsets
606
+ */
607
+ function propagateScreenRect(node: TeaNode, ancestorScrollOffset: number): void {
608
+ // Save previous rects for change detection in notifyLayoutSubscribers
609
+ node.prevScreenRect = node.screenRect
610
+ node.prevRenderRect = node.renderRect
611
+
612
+ const content = node.contentRect
613
+ if (!content) {
614
+ node.screenRect = null
615
+ node.renderRect = null
616
+ for (const child of node.children) {
617
+ propagateScreenRect(child, ancestorScrollOffset)
618
+ }
619
+ return
620
+ }
621
+
622
+ // Compute screen position by subtracting ancestor scroll offsets
623
+ node.screenRect = {
624
+ x: content.x,
625
+ y: content.y - ancestorScrollOffset,
626
+ width: content.width,
627
+ height: content.height,
628
+ }
629
+
630
+ // Default: renderRect equals screenRect (overridden below for sticky nodes)
631
+ node.renderRect = node.screenRect
632
+
633
+ // If this node is a scroll container, add its offset for children
634
+ const scrollOffset = node.scrollState?.offset ?? 0
635
+ const childScrollOffset = ancestorScrollOffset + scrollOffset
636
+
637
+ // Compute renderRect for sticky children.
638
+ // Sticky nodes render at a computed offset instead of their layout position.
639
+ // The offset data lives on the parent (this node) in either scrollState.stickyChildren
640
+ // (for scroll containers) or node.stickyChildren (for non-scroll parents).
641
+ computeStickyRenderRects(node)
642
+
643
+ // Recurse to children
644
+ for (const child of node.children) {
645
+ propagateScreenRect(child, childScrollOffset)
646
+ }
647
+ }
648
+
649
+ /**
650
+ * Compute renderRect for sticky children of a node.
651
+ *
652
+ * For sticky children, the actual render position differs from the layout
653
+ * position (screenRect). The renderOffset from the scroll/sticky phase
654
+ * determines where pixels are actually painted. This function sets
655
+ * renderRect on those children to reflect the true screen position.
656
+ *
657
+ * @param parent The parent node whose sticky children need renderRect computation
658
+ */
659
+ function computeStickyRenderRects(parent: TeaNode): void {
660
+ // Determine which sticky children list to use
661
+ const stickyList = parent.scrollState?.stickyChildren ?? parent.stickyChildren
662
+ if (!stickyList || stickyList.length === 0) return
663
+
664
+ // Calculate the parent's content area origin on screen (inside border/padding)
665
+ const parentScreenRect = parent.screenRect
666
+ if (!parentScreenRect) return
667
+
668
+ const props = parent.props as BoxProps
669
+ const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
670
+ const padding = getPadding(props)
671
+ const contentOriginY = parentScreenRect.y + border.top + padding.top
672
+
673
+ for (const sticky of stickyList) {
674
+ const child = parent.children[sticky.index]
675
+ if (!child?.screenRect) continue
676
+
677
+ // renderRect has the same x, width, height as screenRect,
678
+ // but Y is adjusted to the sticky render position
679
+ child.renderRect = {
680
+ x: child.screenRect.x,
681
+ y: contentOriginY + sticky.renderOffset,
682
+ width: child.screenRect.width,
683
+ height: child.screenRect.height,
684
+ }
685
+ }
686
+ }