@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,1765 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 3: Content Phase
|
|
3
|
+
*
|
|
4
|
+
* Render all nodes to a terminal buffer.
|
|
5
|
+
*
|
|
6
|
+
* This module orchestrates the rendering process by traversing the node tree
|
|
7
|
+
* and delegating to specialized rendering functions for boxes and text.
|
|
8
|
+
*
|
|
9
|
+
* Layout (top-down):
|
|
10
|
+
* contentPhase → renderNodeToBuffer → renderScrollContainerChildren
|
|
11
|
+
* → renderNormalChildren
|
|
12
|
+
* Helpers: clearDirtyFlags, hasChildPositionChanged, computeChildClipBounds
|
|
13
|
+
* Region clearing: findInheritedBg, clearNodeRegion, clippedFill
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { Color } from "../buffer"
|
|
17
|
+
import { TerminalBuffer } from "../buffer"
|
|
18
|
+
import type { BoxProps, TeaNode, TextProps } from "@silvery/tea/types"
|
|
19
|
+
import { getBorderSize, getPadding } from "./helpers"
|
|
20
|
+
import { renderBox, renderOutline, renderScrollIndicators } from "./render-box"
|
|
21
|
+
import { parseColor } from "./render-helpers"
|
|
22
|
+
import { clearBgConflictWarnings, renderText, setBgConflictMode } from "./render-text"
|
|
23
|
+
import { pushContextTheme, popContextTheme } from "@silvery/theme/state"
|
|
24
|
+
import type { Theme } from "@silvery/theme/types"
|
|
25
|
+
import type { ClipBounds, ContentPhaseStats, NodeRenderState, NodeTraceEntry, PipelineContext } from "./types"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Render all nodes to a terminal buffer.
|
|
29
|
+
*
|
|
30
|
+
* @param root The root SilveryNode
|
|
31
|
+
* @param prevBuffer Previous buffer for incremental rendering (optional)
|
|
32
|
+
* @returns A TerminalBuffer with the rendered content
|
|
33
|
+
*/
|
|
34
|
+
export function contentPhase(root: TeaNode, prevBuffer?: TerminalBuffer | null, ctx?: PipelineContext): TerminalBuffer {
|
|
35
|
+
const layout = root.contentRect
|
|
36
|
+
if (!layout) {
|
|
37
|
+
throw new Error("contentPhase called before layout phase")
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Resolve instrumentation from ctx (if provided) or module-level globals
|
|
41
|
+
const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
|
|
42
|
+
const stats = ctx?.stats ?? _contentPhaseStats
|
|
43
|
+
const nodeTrace = ctx?.nodeTrace ?? _nodeTrace
|
|
44
|
+
const nodeTraceEnabled = ctx?.nodeTraceEnabled ?? _nodeTraceEnabled
|
|
45
|
+
|
|
46
|
+
// Clone prevBuffer if same dimensions, else create fresh
|
|
47
|
+
const hasPrevBuffer = prevBuffer && prevBuffer.width === layout.width && prevBuffer.height === layout.height
|
|
48
|
+
|
|
49
|
+
if (instrumentEnabled) {
|
|
50
|
+
_contentPhaseCallCount++
|
|
51
|
+
stats._prevBufferNull = prevBuffer == null ? 1 : 0
|
|
52
|
+
stats._prevBufferDimMismatch = prevBuffer && !hasPrevBuffer ? 1 : 0
|
|
53
|
+
stats._hasPrevBuffer = hasPrevBuffer ? 1 : 0
|
|
54
|
+
stats._layoutW = layout.width
|
|
55
|
+
stats._layoutH = layout.height
|
|
56
|
+
stats._prevW = prevBuffer?.width ?? 0
|
|
57
|
+
stats._prevH = prevBuffer?.height ?? 0
|
|
58
|
+
stats._callCount = _contentPhaseCallCount
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const t0 = instrumentEnabled ? performance.now() : 0
|
|
62
|
+
const buffer = hasPrevBuffer ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height)
|
|
63
|
+
const tClone = instrumentEnabled ? performance.now() - t0 : 0
|
|
64
|
+
|
|
65
|
+
const t1 = instrumentEnabled ? performance.now() : 0
|
|
66
|
+
renderNodeToBuffer(
|
|
67
|
+
root,
|
|
68
|
+
buffer,
|
|
69
|
+
{
|
|
70
|
+
scrollOffset: 0,
|
|
71
|
+
clipBounds: undefined,
|
|
72
|
+
hasPrevBuffer: !!hasPrevBuffer,
|
|
73
|
+
ancestorCleared: false,
|
|
74
|
+
bufferIsCloned: !!hasPrevBuffer,
|
|
75
|
+
ancestorLayoutChanged: false,
|
|
76
|
+
},
|
|
77
|
+
ctx,
|
|
78
|
+
)
|
|
79
|
+
const tRender = instrumentEnabled ? performance.now() - t1 : 0
|
|
80
|
+
|
|
81
|
+
if (instrumentEnabled) {
|
|
82
|
+
// Expose sub-phase timing for profiling
|
|
83
|
+
const snap = {
|
|
84
|
+
clone: tClone,
|
|
85
|
+
render: tRender,
|
|
86
|
+
...structuredClone(stats),
|
|
87
|
+
}
|
|
88
|
+
;(globalThis as any).__silvery_content_detail = snap
|
|
89
|
+
const arr = ((globalThis as any).__silvery_content_all ??= [] as (typeof snap)[])
|
|
90
|
+
arr.push(snap)
|
|
91
|
+
for (const key of Object.keys(stats) as (keyof ContentPhaseStats)[]) {
|
|
92
|
+
;(stats as any)[key] = 0
|
|
93
|
+
}
|
|
94
|
+
stats.cascadeMinDepth = 999
|
|
95
|
+
stats.cascadeNodes = ""
|
|
96
|
+
stats.scrollClearReason = ""
|
|
97
|
+
stats.normalRepaintReason = ""
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Export node trace for SILVERY_STRICT diagnosis
|
|
101
|
+
if (nodeTraceEnabled && nodeTrace.length > 0) {
|
|
102
|
+
const traceArr = ((globalThis as any).__silvery_node_trace ??= [] as NodeTraceEntry[][])
|
|
103
|
+
traceArr.push([...nodeTrace])
|
|
104
|
+
nodeTrace.length = 0
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Sync prevLayout after content phase to prevent staleness on subsequent frames.
|
|
108
|
+
// Without this, prevLayout stays at the old value from propagateLayout, causing
|
|
109
|
+
// hasChildPositionChanged and clearExcessArea to use stale coordinates.
|
|
110
|
+
syncPrevLayout(root)
|
|
111
|
+
|
|
112
|
+
return buffer
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Sync prevLayout to contentRect for all nodes in the tree.
|
|
117
|
+
*
|
|
118
|
+
* Called at the end of each contentPhase pass. This prevents:
|
|
119
|
+
* 1. The O(N) staleness bug where prevLayout drifts from contentRect
|
|
120
|
+
* causing !rectEqual to always be true on subsequent frames.
|
|
121
|
+
* 2. Stale old-bounds references in clearExcessArea on doRender iteration 2+.
|
|
122
|
+
* 3. Asymmetry between incremental and fresh renders — doFreshRender's layout
|
|
123
|
+
* phase syncs prevLayout before content, so without this, the real render
|
|
124
|
+
* has null/stale prevLayout while fresh has synced prevLayout, causing
|
|
125
|
+
* different cascade behavior (layoutChanged true vs false).
|
|
126
|
+
*/
|
|
127
|
+
function syncPrevLayout(node: TeaNode): void {
|
|
128
|
+
node.prevLayout = node.contentRect
|
|
129
|
+
for (const child of node.children) {
|
|
130
|
+
syncPrevLayout(child)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Instrumentation enabled when SILVERY_STRICT, SILVERY_CHECK_INCREMENTAL, or SILVERY_INSTRUMENT is set */
|
|
135
|
+
const _instrumentEnabled =
|
|
136
|
+
typeof process !== "undefined" &&
|
|
137
|
+
!!(process.env?.SILVERY_STRICT || process.env?.SILVERY_CHECK_INCREMENTAL || process.env?.SILVERY_INSTRUMENT)
|
|
138
|
+
|
|
139
|
+
/** Mutable stats counters — reset after each contentPhase call */
|
|
140
|
+
const _contentPhaseStats: ContentPhaseStats = {
|
|
141
|
+
nodesVisited: 0,
|
|
142
|
+
nodesRendered: 0,
|
|
143
|
+
nodesSkipped: 0,
|
|
144
|
+
textNodes: 0,
|
|
145
|
+
boxNodes: 0,
|
|
146
|
+
clearOps: 0,
|
|
147
|
+
noPrevBuffer: 0,
|
|
148
|
+
flagContentDirty: 0,
|
|
149
|
+
flagPaintDirty: 0,
|
|
150
|
+
flagLayoutChanged: 0,
|
|
151
|
+
flagSubtreeDirty: 0,
|
|
152
|
+
flagChildrenDirty: 0,
|
|
153
|
+
flagChildPositionChanged: 0,
|
|
154
|
+
flagAncestorLayoutChanged: 0,
|
|
155
|
+
scrollContainerCount: 0,
|
|
156
|
+
scrollViewportCleared: 0,
|
|
157
|
+
scrollClearReason: "",
|
|
158
|
+
normalChildrenRepaint: 0,
|
|
159
|
+
normalRepaintReason: "",
|
|
160
|
+
cascadeMinDepth: 999,
|
|
161
|
+
cascadeNodes: "",
|
|
162
|
+
_prevBufferNull: 0,
|
|
163
|
+
_prevBufferDimMismatch: 0,
|
|
164
|
+
_hasPrevBuffer: 0,
|
|
165
|
+
_layoutW: 0,
|
|
166
|
+
_layoutH: 0,
|
|
167
|
+
_prevW: 0,
|
|
168
|
+
_prevH: 0,
|
|
169
|
+
_callCount: 0,
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let _contentPhaseCallCount = 0
|
|
173
|
+
|
|
174
|
+
/** Module-level node trace (fallback when ctx.nodeTrace is not provided) */
|
|
175
|
+
const _nodeTrace: NodeTraceEntry[] = []
|
|
176
|
+
const _nodeTraceEnabled =
|
|
177
|
+
typeof process !== "undefined" && !!(process.env?.SILVERY_STRICT || process.env?.SILVERY_CHECK_INCREMENTAL)
|
|
178
|
+
|
|
179
|
+
/** DIAG: compute node depth in tree */
|
|
180
|
+
function _getNodeDepth(node: TeaNode): number {
|
|
181
|
+
let depth = 0
|
|
182
|
+
let n: TeaNode | null = node.parent
|
|
183
|
+
while (n) {
|
|
184
|
+
depth++
|
|
185
|
+
n = n.parent
|
|
186
|
+
}
|
|
187
|
+
return depth
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Re-export for consumers who need to clear bg conflict warnings
|
|
191
|
+
export { clearBgConflictWarnings, setBgConflictMode }
|
|
192
|
+
|
|
193
|
+
// ============================================================================
|
|
194
|
+
// Core Rendering
|
|
195
|
+
// ============================================================================
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Render a single node to the buffer.
|
|
199
|
+
*/
|
|
200
|
+
function renderNodeToBuffer(
|
|
201
|
+
node: TeaNode,
|
|
202
|
+
buffer: TerminalBuffer,
|
|
203
|
+
nodeState: NodeRenderState,
|
|
204
|
+
ctx?: PipelineContext,
|
|
205
|
+
): void {
|
|
206
|
+
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged } = nodeState
|
|
207
|
+
// Resolve instrumentation from ctx or module globals
|
|
208
|
+
const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
|
|
209
|
+
const stats = ctx?.stats ?? _contentPhaseStats
|
|
210
|
+
const nodeTrace = ctx?.nodeTrace ?? _nodeTrace
|
|
211
|
+
const nodeTraceEnabled = ctx?.nodeTraceEnabled ?? _nodeTraceEnabled
|
|
212
|
+
if (instrumentEnabled) stats.nodesVisited++
|
|
213
|
+
const layout = node.contentRect
|
|
214
|
+
if (!layout) return
|
|
215
|
+
|
|
216
|
+
// Skip nodes without Yoga (raw text and virtual text nodes)
|
|
217
|
+
// Their content is rendered by their parent silvery-text via collectTextContent()
|
|
218
|
+
if (!node.layoutNode) {
|
|
219
|
+
// Clear dirty flags so markSubtreeDirty() can propagate future updates.
|
|
220
|
+
// Without this, virtual text children keep stale subtreeDirty=true from
|
|
221
|
+
// creation, causing markSubtreeDirty to stop early and never reach the
|
|
222
|
+
// layout ancestor — producing 0-byte diffs on text content changes.
|
|
223
|
+
clearVirtualTextFlags(node)
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Skip hidden nodes (Suspense support)
|
|
228
|
+
// When a Suspense boundary shows a fallback, the hidden subtree is not rendered
|
|
229
|
+
if (node.hidden) {
|
|
230
|
+
clearDirtyFlags(node)
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const props = node.props as BoxProps & TextProps
|
|
235
|
+
|
|
236
|
+
// Skip display="none" nodes - they have 0x0 dimensions and shouldn't render
|
|
237
|
+
// Also skip their children since the entire subtree is hidden
|
|
238
|
+
if (props.display === "none") {
|
|
239
|
+
clearDirtyFlags(node)
|
|
240
|
+
return
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Skip nodes entirely off-screen (viewport clipping).
|
|
244
|
+
// The scroll container's VirtualList already handles most culling, but this
|
|
245
|
+
// catches any remaining nodes rendered below/above the visible area.
|
|
246
|
+
//
|
|
247
|
+
// IMPORTANT: Don't clear dirty flags on nodes that were never rendered.
|
|
248
|
+
// Just skip them and leave their flags intact so they render correctly
|
|
249
|
+
// when scrolled into view.
|
|
250
|
+
//
|
|
251
|
+
// Why this matters: clearDirtyFlags on off-screen nodes prevents them from
|
|
252
|
+
// rendering when they later become visible:
|
|
253
|
+
// 1. Node off-screen → clearDirtyFlags → all flags false
|
|
254
|
+
// 2. Scroll brings node on-screen with hasPrevBuffer=true
|
|
255
|
+
// 3. skipFastPath = true (all flags clean) → node SKIPPED
|
|
256
|
+
// 4. Buffer has stale/blank pixels → blank content visible
|
|
257
|
+
//
|
|
258
|
+
// By preserving dirty flags, the node forces rendering when it enters
|
|
259
|
+
// the visible area. The subtreeDirty flag on ancestors is maintained
|
|
260
|
+
// because we don't clear it — markSubtreeDirty() already set it during
|
|
261
|
+
// reconciliation/layout, and not clearing here preserves that signal.
|
|
262
|
+
const screenY = layout.y - scrollOffset
|
|
263
|
+
if (screenY >= buffer.height || screenY + layout.height <= 0) {
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// FAST PATH: Skip entire subtree if unchanged and we have a previous buffer
|
|
268
|
+
// The buffer was cloned from prevBuffer, so skipped nodes keep their rendered output
|
|
269
|
+
//
|
|
270
|
+
// layoutChanged: did this node's layout position/size change?
|
|
271
|
+
// Uses layoutChangedThisFrame (set by propagateLayout in layout phase) instead of
|
|
272
|
+
// the stale !rectEqual(prevLayout, contentRect). The rect comparison is asymmetric
|
|
273
|
+
// between incremental and fresh renders: doFreshRender's layout phase syncs
|
|
274
|
+
// prevLayout=contentRect before content, making layoutChanged=false, while the
|
|
275
|
+
// real render may have prevLayout=null (new nodes), making layoutChanged=true.
|
|
276
|
+
// This asymmetry causes contentAreaAffected→clearNodeRegion to fire in incremental
|
|
277
|
+
// but not fresh, wiping sibling content. layoutChangedThisFrame is symmetric.
|
|
278
|
+
const layoutChanged = node.layoutChangedThisFrame
|
|
279
|
+
|
|
280
|
+
// Check if any child shifted position (sibling shift from size changes).
|
|
281
|
+
// Gap space between children belongs to this container, so must re-render.
|
|
282
|
+
const childPositionChanged = hasPrevBuffer && !layoutChanged && hasChildPositionChanged(node)
|
|
283
|
+
|
|
284
|
+
// FAST PATH: Skip unchanged subtrees when we have a valid previous buffer.
|
|
285
|
+
// The cloned buffer already has correct pixels for clean nodes.
|
|
286
|
+
// SILVERY_STRICT=1 verifies this by comparing incremental vs fresh renders.
|
|
287
|
+
//
|
|
288
|
+
// ancestorLayoutChanged: an ancestor's layout position/size changed this frame.
|
|
289
|
+
// Even if this node's own flags are clean, its pixels in the cloned buffer are
|
|
290
|
+
// at coordinates relative to the old ancestor layout. The node must re-render
|
|
291
|
+
// at its new absolute position. This is a safety net — normally the parent's
|
|
292
|
+
// parentRegionChanged cascade sets childHasPrev=false which prevents skipping,
|
|
293
|
+
// but ancestorLayoutChanged catches cases where the cascade doesn't propagate
|
|
294
|
+
// (e.g., ancestor with backgroundColor that breaks the ancestorCleared chain).
|
|
295
|
+
const skipFastPath =
|
|
296
|
+
hasPrevBuffer &&
|
|
297
|
+
!node.contentDirty &&
|
|
298
|
+
!node.paintDirty &&
|
|
299
|
+
!layoutChanged &&
|
|
300
|
+
!node.subtreeDirty &&
|
|
301
|
+
!node.childrenDirty &&
|
|
302
|
+
!childPositionChanged &&
|
|
303
|
+
!ancestorLayoutChanged
|
|
304
|
+
|
|
305
|
+
// Node ID for tracing (only trace named nodes to keep compact)
|
|
306
|
+
const _nodeId = instrumentEnabled ? ((props.id as string | undefined) ?? "") : ""
|
|
307
|
+
const _traceThis = instrumentEnabled && nodeTraceEnabled && _nodeId
|
|
308
|
+
|
|
309
|
+
// Cell debug: log nodes that cover the target cell
|
|
310
|
+
const _cellDbg = (globalThis as any).__silvery_cell_debug as { x: number; y: number; log: string[] } | undefined
|
|
311
|
+
const _coversCellNow =
|
|
312
|
+
_cellDbg &&
|
|
313
|
+
layout.x <= _cellDbg.x &&
|
|
314
|
+
layout.x + layout.width > _cellDbg.x &&
|
|
315
|
+
screenY <= _cellDbg.y &&
|
|
316
|
+
screenY + layout.height > _cellDbg.y
|
|
317
|
+
const _coversCellPrev =
|
|
318
|
+
_cellDbg &&
|
|
319
|
+
node.prevLayout &&
|
|
320
|
+
node.prevLayout.x <= _cellDbg.x &&
|
|
321
|
+
node.prevLayout.x + node.prevLayout.width > _cellDbg.x &&
|
|
322
|
+
node.prevLayout.y - scrollOffset <= _cellDbg.y &&
|
|
323
|
+
node.prevLayout.y - scrollOffset + node.prevLayout.height > _cellDbg.y
|
|
324
|
+
|
|
325
|
+
if (skipFastPath) {
|
|
326
|
+
if (_cellDbg && (_coversCellNow || _coversCellPrev)) {
|
|
327
|
+
const id = (props.id as string) ?? node.type
|
|
328
|
+
const depth = _getNodeDepth(node)
|
|
329
|
+
const prev = node.prevLayout
|
|
330
|
+
_cellDbg.log.push(
|
|
331
|
+
`SKIP ${id}@${depth} rect=${layout.x},${screenY} ${layout.width}x${layout.height}` +
|
|
332
|
+
` prev=${prev ? `${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` : "null"}` +
|
|
333
|
+
` coversNow=${_coversCellNow} coversPrev=${_coversCellPrev}`,
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
if (instrumentEnabled) {
|
|
337
|
+
stats.nodesSkipped++
|
|
338
|
+
if (_traceThis) {
|
|
339
|
+
nodeTrace.push({
|
|
340
|
+
id: _nodeId,
|
|
341
|
+
type: node.type,
|
|
342
|
+
depth: _getNodeDepth(node),
|
|
343
|
+
rect: `${layout.x},${layout.y} ${layout.width}x${layout.height}`,
|
|
344
|
+
prevLayout: node.prevLayout
|
|
345
|
+
? `${node.prevLayout.x},${node.prevLayout.y} ${node.prevLayout.width}x${node.prevLayout.height}`
|
|
346
|
+
: "null",
|
|
347
|
+
hasPrev: hasPrevBuffer,
|
|
348
|
+
ancestorCleared,
|
|
349
|
+
flags: "",
|
|
350
|
+
decision: "SKIPPED",
|
|
351
|
+
layoutChanged,
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
clearDirtyFlags(node)
|
|
356
|
+
return
|
|
357
|
+
}
|
|
358
|
+
if (instrumentEnabled) {
|
|
359
|
+
stats.nodesRendered++
|
|
360
|
+
if (!hasPrevBuffer) stats.noPrevBuffer++
|
|
361
|
+
if (node.contentDirty) stats.flagContentDirty++
|
|
362
|
+
if (node.paintDirty) stats.flagPaintDirty++
|
|
363
|
+
if (layoutChanged) stats.flagLayoutChanged++
|
|
364
|
+
if (node.subtreeDirty) stats.flagSubtreeDirty++
|
|
365
|
+
if (node.childrenDirty) stats.flagChildrenDirty++
|
|
366
|
+
if (childPositionChanged) stats.flagChildPositionChanged++
|
|
367
|
+
if (ancestorLayoutChanged) stats.flagAncestorLayoutChanged++
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Push per-subtree theme override (if this Box has a theme prop).
|
|
371
|
+
// Placed after all early returns and fast-path skip — only active during
|
|
372
|
+
// actual rendering. Popped at the end of this function after all child passes.
|
|
373
|
+
const nodeTheme = (props as BoxProps).theme as Theme | undefined
|
|
374
|
+
if (nodeTheme) pushContextTheme(nodeTheme)
|
|
375
|
+
try {
|
|
376
|
+
// Check if this is a scrollable container
|
|
377
|
+
const isScrollContainer = props.overflow === "scroll" && node.scrollState
|
|
378
|
+
|
|
379
|
+
// Does this node's OWN visual state need re-rendering?
|
|
380
|
+
// True when content/style changed, children restructured, or layout shifted.
|
|
381
|
+
// (Not true for subtreeDirty alone — that only means descendants changed.)
|
|
382
|
+
//
|
|
383
|
+
// Why paintDirty: measure phase may clear contentDirty for its text-collection
|
|
384
|
+
// cache, so paintDirty acts as a surviving witness that style props changed.
|
|
385
|
+
// Why this matters: when backgroundColor changes from "cyan" to undefined,
|
|
386
|
+
// paintDirty ensures we clear stale pixels from the cloned buffer.
|
|
387
|
+
// needsOwnRepaint = node.contentDirty || node.paintDirty || node.childrenDirty || layoutChanged || childPositionChanged
|
|
388
|
+
|
|
389
|
+
// contentAreaAffected: did this node's CONTENT AREA change (not just border)?
|
|
390
|
+
// Excludes border-only paint changes for BOX nodes: renderBox only draws border
|
|
391
|
+
// chars at edges, content area pixels are untouched. This avoids cascading ~200
|
|
392
|
+
// node re-renders per Card on cursor move (borderColor changes yellow↔blackBright
|
|
393
|
+
// but content area is unchanged).
|
|
394
|
+
//
|
|
395
|
+
// For TEXT nodes, paintDirty IS included because text nodes have no borders —
|
|
396
|
+
// any paint change (color, bold, inverse, or text content change) affects the
|
|
397
|
+
// content area. The measure phase clears contentDirty for its text-collection
|
|
398
|
+
// cache, so paintDirty acts as the surviving witness that the text node's
|
|
399
|
+
// content area changed and needs region clearing. Without this, stale pixels
|
|
400
|
+
// (e.g., cursor inverse attribute) persist when text content changes but
|
|
401
|
+
// layout dimensions stay the same.
|
|
402
|
+
//
|
|
403
|
+
// Uses bgDirty (set by reconciler when backgroundColor specifically changes) rather
|
|
404
|
+
// than checking current props.backgroundColor — catches bg removal (cyan → undefined)
|
|
405
|
+
// where current value is falsy but stale pixels must still be cleared.
|
|
406
|
+
const textPaintDirty = node.type === "silvery-text" && node.paintDirty
|
|
407
|
+
|
|
408
|
+
// absoluteChildMutated: an absolute child had its children added/removed/reordered,
|
|
409
|
+
// or its layout changed. In the two-pass rendering model (normal-flow first, absolute
|
|
410
|
+
// second), the cloned buffer contains BOTH first-pass content AND stale overlay pixels
|
|
411
|
+
// from the previous frame. When an absolute child's content structure changes (e.g.,
|
|
412
|
+
// a dialog unmounts), its old pixels persist at positions not covered by any current
|
|
413
|
+
// child. By including this in contentAreaAffected, the parent clears its region
|
|
414
|
+
// (removing stale overlay pixels in gap areas) and forces normal-flow children to
|
|
415
|
+
// re-render on the cleared background — matching fresh render behavior.
|
|
416
|
+
//
|
|
417
|
+
// Only checked when hasPrevBuffer (incremental mode) and subtreeDirty (a descendant
|
|
418
|
+
// changed somewhere). The scan is cheap: only direct children are checked.
|
|
419
|
+
const absoluteChildMutated =
|
|
420
|
+
hasPrevBuffer &&
|
|
421
|
+
node.subtreeDirty &&
|
|
422
|
+
node.children !== undefined &&
|
|
423
|
+
node.children.some((child) => {
|
|
424
|
+
const cp = child.props as BoxProps
|
|
425
|
+
return (
|
|
426
|
+
cp.position === "absolute" &&
|
|
427
|
+
(child.childrenDirty || child.layoutChangedThisFrame || hasChildPositionChanged(child))
|
|
428
|
+
)
|
|
429
|
+
})
|
|
430
|
+
|
|
431
|
+
// descendantOverflowChanged: a descendant (child, grandchild, etc.) was overflowing
|
|
432
|
+
// THIS node's rect in the previous frame and its layout changed (shrank/moved).
|
|
433
|
+
// clearExcessArea on the descendant clips to its immediate parent's content area,
|
|
434
|
+
// leaving stale pixels in THIS node's border/padding area and beyond THIS node's rect.
|
|
435
|
+
// By including this in contentAreaAffected, the node clears its region (removing stale
|
|
436
|
+
// overflow pixels, restoring borders) and forces children to re-render. The overflow
|
|
437
|
+
// area beyond the node's rect is handled by clearDescendantOverflowRegions.
|
|
438
|
+
//
|
|
439
|
+
// Recursive detection is critical: a grandchild overflowing a child AND this node
|
|
440
|
+
// must be caught at THIS level so the border is properly restored (depth 5 detects
|
|
441
|
+
// depth 7's overflow rather than depth 6 clearing into depth 5's border area).
|
|
442
|
+
const descendantOverflowChanged =
|
|
443
|
+
hasPrevBuffer && node.subtreeDirty && node.children !== undefined && hasDescendantOverflowChanged(node)
|
|
444
|
+
|
|
445
|
+
const contentAreaAffected =
|
|
446
|
+
node.contentDirty ||
|
|
447
|
+
layoutChanged ||
|
|
448
|
+
childPositionChanged ||
|
|
449
|
+
node.childrenDirty ||
|
|
450
|
+
node.bgDirty ||
|
|
451
|
+
textPaintDirty ||
|
|
452
|
+
absoluteChildMutated ||
|
|
453
|
+
descendantOverflowChanged
|
|
454
|
+
|
|
455
|
+
// subtreeDirtyWithBg: a descendant changed inside a Box with backgroundColor.
|
|
456
|
+
// When a child Text shrinks, trailing chars from the old longer text survive in
|
|
457
|
+
// the cloned buffer. The parent's bg fill must re-run to clear them, and children
|
|
458
|
+
// must re-render on top of the fresh fill. This is NOT added to contentAreaAffected
|
|
459
|
+
// because non-bg boxes don't need region clearing for subtreeDirty — only bg-bearing
|
|
460
|
+
// boxes need their fill to overwrite stale child pixels.
|
|
461
|
+
const subtreeDirtyWithBg = hasPrevBuffer && !contentAreaAffected && node.subtreeDirty && !!props.backgroundColor
|
|
462
|
+
|
|
463
|
+
// Clear this node's region when its content area changed but has no backgroundColor.
|
|
464
|
+
// Without bg, renderBox won't fill, so stale pixels from the cloned buffer
|
|
465
|
+
// remain visible. We must explicitly clear with inherited bg.
|
|
466
|
+
//
|
|
467
|
+
// Gated on (hasPrevBuffer || ancestorCleared) because:
|
|
468
|
+
// - hasPrevBuffer=true: buffer is a clone with stale pixels
|
|
469
|
+
// - ancestorCleared=true: buffer is a clone but hasPrevBuffer=false was passed
|
|
470
|
+
// (ancestor cleared its region, but this node may need to clear its sub-region)
|
|
471
|
+
// On a truly fresh buffer (first render), both are false — no wasteful clear.
|
|
472
|
+
const parentRegionCleared = (hasPrevBuffer || ancestorCleared) && contentAreaAffected && !props.backgroundColor
|
|
473
|
+
|
|
474
|
+
// skipBgFill: in incremental mode, skip the bg fill when the cloned buffer
|
|
475
|
+
// already has the correct bg at this node's position. That's ONLY when:
|
|
476
|
+
// - hasPrevBuffer=true (buffer is a clone with previous frame's pixels)
|
|
477
|
+
// - ancestorCleared=false (no ancestor erased our region)
|
|
478
|
+
// - contentAreaAffected=false (no content-area changes)
|
|
479
|
+
// - subtreeDirtyWithBg=false (no descendant change requiring bg refresh)
|
|
480
|
+
//
|
|
481
|
+
// Uses contentAreaAffected (not needsOwnRepaint) because border-only changes
|
|
482
|
+
// (paintDirty without bgDirty) don't change the bg fill — the cloned buffer
|
|
483
|
+
// already has the correct bg. Using needsOwnRepaint here caused bg fill to
|
|
484
|
+
// wipe child content on borderColor changes, while parentRegionChanged=false
|
|
485
|
+
// (from contentAreaAffected) prevented children from re-rendering to restore it.
|
|
486
|
+
//
|
|
487
|
+
// When ancestorCleared=true, the buffer at our position was erased to the
|
|
488
|
+
// inherited bg, NOT our bg — so we must re-fill.
|
|
489
|
+
// When hasPrevBuffer=false AND ancestorCleared=false, it's a fresh render.
|
|
490
|
+
const skipBgFill = hasPrevBuffer && !ancestorCleared && !contentAreaAffected && !subtreeDirtyWithBg
|
|
491
|
+
|
|
492
|
+
// parentRegionChanged: this node's content area was modified on a cloned buffer.
|
|
493
|
+
// Children must re-render (childHasPrev=false) because their pixels may be stale.
|
|
494
|
+
const parentRegionChanged = (hasPrevBuffer || ancestorCleared) && (contentAreaAffected || subtreeDirtyWithBg)
|
|
495
|
+
|
|
496
|
+
// DIAG: Per-node trace and cascade tracking (gated on instrumentation)
|
|
497
|
+
if (instrumentEnabled) {
|
|
498
|
+
if (_traceThis) {
|
|
499
|
+
const flagStr = [
|
|
500
|
+
node.contentDirty && "C",
|
|
501
|
+
node.paintDirty && "P",
|
|
502
|
+
node.bgDirty && "B",
|
|
503
|
+
node.subtreeDirty && "S",
|
|
504
|
+
node.childrenDirty && "Ch",
|
|
505
|
+
childPositionChanged && "CP",
|
|
506
|
+
]
|
|
507
|
+
.filter(Boolean)
|
|
508
|
+
.join(",")
|
|
509
|
+
const childrenNeedRepaint_ = node.childrenDirty || childPositionChanged || parentRegionChanged
|
|
510
|
+
const childHasPrev_ = childrenNeedRepaint_ ? false : hasPrevBuffer
|
|
511
|
+
const childAncestorCleared_ = parentRegionCleared || (ancestorCleared && !props.backgroundColor)
|
|
512
|
+
nodeTrace.push({
|
|
513
|
+
id: _nodeId,
|
|
514
|
+
type: node.type,
|
|
515
|
+
depth: _getNodeDepth(node),
|
|
516
|
+
rect: `${layout.x},${layout.y} ${layout.width}x${layout.height}`,
|
|
517
|
+
prevLayout: node.prevLayout
|
|
518
|
+
? `${node.prevLayout.x},${node.prevLayout.y} ${node.prevLayout.width}x${node.prevLayout.height}`
|
|
519
|
+
: "null",
|
|
520
|
+
hasPrev: hasPrevBuffer,
|
|
521
|
+
ancestorCleared,
|
|
522
|
+
flags: flagStr,
|
|
523
|
+
decision: "RENDER",
|
|
524
|
+
layoutChanged,
|
|
525
|
+
contentAreaAffected,
|
|
526
|
+
parentRegionCleared,
|
|
527
|
+
parentRegionChanged,
|
|
528
|
+
childHasPrev: childHasPrev_,
|
|
529
|
+
childAncestorCleared: childAncestorCleared_,
|
|
530
|
+
skipBgFill,
|
|
531
|
+
bgColor: props.backgroundColor as string | undefined,
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
if (parentRegionChanged && node.children.length > 0) {
|
|
535
|
+
const depth = _getNodeDepth(node)
|
|
536
|
+
if (depth < stats.cascadeMinDepth) {
|
|
537
|
+
stats.cascadeMinDepth = depth
|
|
538
|
+
}
|
|
539
|
+
const id = (node.props as Record<string, unknown>).id ?? node.type
|
|
540
|
+
const flags = [
|
|
541
|
+
node.contentDirty && "C",
|
|
542
|
+
node.paintDirty && "P",
|
|
543
|
+
node.childrenDirty && "Ch",
|
|
544
|
+
layoutChanged && "L",
|
|
545
|
+
childPositionChanged && "CP",
|
|
546
|
+
]
|
|
547
|
+
.filter(Boolean)
|
|
548
|
+
.join("")
|
|
549
|
+
const entry = `${id}@${depth}[${flags}:${node.children.length}ch]`
|
|
550
|
+
stats.cascadeNodes += (stats.cascadeNodes ? " " : "") + entry
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// Cell debug: log render decision for nodes covering target cell
|
|
555
|
+
if (_cellDbg && (_coversCellNow || _coversCellPrev)) {
|
|
556
|
+
const id = (props.id as string) ?? node.type
|
|
557
|
+
const depth = _getNodeDepth(node)
|
|
558
|
+
const prev = node.prevLayout
|
|
559
|
+
const flags = [
|
|
560
|
+
node.contentDirty && "C",
|
|
561
|
+
node.paintDirty && "P",
|
|
562
|
+
layoutChanged && "L",
|
|
563
|
+
node.subtreeDirty && "S",
|
|
564
|
+
node.childrenDirty && "Ch",
|
|
565
|
+
childPositionChanged && "CP",
|
|
566
|
+
node.bgDirty && "B",
|
|
567
|
+
]
|
|
568
|
+
.filter(Boolean)
|
|
569
|
+
.join(",")
|
|
570
|
+
_cellDbg.log.push(
|
|
571
|
+
`RENDER ${id}@${depth} rect=${layout.x},${screenY} ${layout.width}x${layout.height}` +
|
|
572
|
+
` prev=${prev ? `${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` : "null"}` +
|
|
573
|
+
` flags=[${flags}] hasPrev=${hasPrevBuffer} ancClr=${ancestorCleared}` +
|
|
574
|
+
` caa=${contentAreaAffected} prc=${parentRegionCleared} prm=${parentRegionChanged}` +
|
|
575
|
+
` coversNow=${_coversCellNow} coversPrev=${_coversCellPrev}` +
|
|
576
|
+
` bg=${props.backgroundColor ?? "none"}`,
|
|
577
|
+
)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (parentRegionCleared) {
|
|
581
|
+
if (instrumentEnabled) stats.clearOps++
|
|
582
|
+
clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutChanged)
|
|
583
|
+
} else if (bufferIsCloned && layoutChanged && node.prevLayout) {
|
|
584
|
+
// Even when parentRegionCleared is false, a shrinking node needs its excess
|
|
585
|
+
// area cleared. Key scenario: absolute-positioned overlays (e.g., search dialog)
|
|
586
|
+
// that shrink while normal-flow siblings are dirty — forceRepaint sets
|
|
587
|
+
// hasPrevBuffer=false + ancestorCleared=false, making parentRegionCleared=false,
|
|
588
|
+
// but the cloned buffer still has stale pixels from the old larger layout.
|
|
589
|
+
// Also applies to nodes WITH backgroundColor: renderBox fills only the NEW
|
|
590
|
+
// (smaller) region, leaving stale pixels in the excess area.
|
|
591
|
+
//
|
|
592
|
+
// Gated on bufferIsCloned: on a fresh buffer (e.g., multi-pass resize where
|
|
593
|
+
// dimensions changed between passes), there are no stale pixels to clear.
|
|
594
|
+
// Without this guard, clearExcessArea writes inherited bg into cells that
|
|
595
|
+
// doFreshRender leaves as default, causing STRICT mismatches.
|
|
596
|
+
clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Clear descendant overflow regions: areas where descendants' previous layouts
|
|
600
|
+
// extended beyond THIS node's rect. clearNodeRegion covers the node's interior,
|
|
601
|
+
// but overflow content is beyond it. This is separate from parentRegionCleared
|
|
602
|
+
// because overflow is OUTSIDE the rect — it needs clearing even for nodes with
|
|
603
|
+
// backgroundColor (whose interior is handled by renderBox's bg fill).
|
|
604
|
+
// Runs BEFORE renderBox so borders drawn by renderBox aren't overwritten.
|
|
605
|
+
if (descendantOverflowChanged) {
|
|
606
|
+
clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds)
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Compute inherited bg once for boxes — used by border and outline rendering
|
|
610
|
+
// to preserve parent backgrounds on border cells (prevents transparent holes).
|
|
611
|
+
const boxInheritedBg =
|
|
612
|
+
node.type === "silvery-box" && !props.backgroundColor ? findInheritedBg(node).color : undefined
|
|
613
|
+
|
|
614
|
+
// Render based on node type
|
|
615
|
+
if (node.type === "silvery-box") {
|
|
616
|
+
if (instrumentEnabled) stats.boxNodes++
|
|
617
|
+
renderBox(node, buffer, layout, props, nodeState, skipBgFill, boxInheritedBg)
|
|
618
|
+
} else if (node.type === "silvery-text") {
|
|
619
|
+
if (instrumentEnabled) stats.textNodes++
|
|
620
|
+
// Pass inherited bg/fg from nearest ancestor with backgroundColor/color.
|
|
621
|
+
// inheritedBg decouples text rendering from buffer state, which is critical
|
|
622
|
+
// for incremental rendering: the cloned buffer may have stale bg at positions
|
|
623
|
+
// outside the parent's bg-filled region (e.g., overflow text, moved nodes).
|
|
624
|
+
// Foreground inheritance matches CSS semantics: Box color cascades to Text children.
|
|
625
|
+
const textInheritedBg = findInheritedBg(node).color
|
|
626
|
+
const textInheritedFg = findInheritedFg(node)
|
|
627
|
+
renderText(node, buffer, layout, props, nodeState, textInheritedBg, textInheritedFg, ctx)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Render children
|
|
631
|
+
if (isScrollContainer) {
|
|
632
|
+
renderScrollContainerChildren(node, buffer, props, nodeState, parentRegionCleared, parentRegionChanged, ctx)
|
|
633
|
+
|
|
634
|
+
// Render overflow indicators AFTER children so they survive viewport clear.
|
|
635
|
+
// renderScrollContainerChildren may clear the viewport (Tier 2) which would
|
|
636
|
+
// overwrite indicators drawn before children.
|
|
637
|
+
renderScrollIndicators(node, buffer, layout, props, node.scrollState!, ctx)
|
|
638
|
+
} else {
|
|
639
|
+
renderNormalChildren(
|
|
640
|
+
node,
|
|
641
|
+
buffer,
|
|
642
|
+
props,
|
|
643
|
+
nodeState,
|
|
644
|
+
childPositionChanged,
|
|
645
|
+
parentRegionCleared,
|
|
646
|
+
parentRegionChanged,
|
|
647
|
+
ctx,
|
|
648
|
+
)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Render outline AFTER children — outline overlaps content at edges
|
|
652
|
+
if (node.type === "silvery-box" && props.outlineStyle) {
|
|
653
|
+
const { x, width, height } = layout
|
|
654
|
+
const y = layout.y - scrollOffset
|
|
655
|
+
renderOutline(buffer, x, y, width, height, props, clipBounds, boxInheritedBg)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Clear dirty flags (current node only — children clear their own when rendered)
|
|
659
|
+
clearNodeDirtyFlags(node)
|
|
660
|
+
} finally {
|
|
661
|
+
// Pop per-subtree theme override (after ALL child passes including absolute/sticky)
|
|
662
|
+
if (nodeTheme) popContextTheme()
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* Render children of a scroll container with proper clipping and offset.
|
|
668
|
+
*/
|
|
669
|
+
function renderScrollContainerChildren(
|
|
670
|
+
node: TeaNode,
|
|
671
|
+
buffer: TerminalBuffer,
|
|
672
|
+
props: BoxProps,
|
|
673
|
+
nodeState: NodeRenderState,
|
|
674
|
+
parentRegionCleared = false,
|
|
675
|
+
parentRegionChanged = false,
|
|
676
|
+
ctx?: PipelineContext,
|
|
677
|
+
): void {
|
|
678
|
+
const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged } = nodeState
|
|
679
|
+
// Resolve instrumentation from ctx or module globals
|
|
680
|
+
const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
|
|
681
|
+
const stats = ctx?.stats ?? _contentPhaseStats
|
|
682
|
+
const layout = node.contentRect
|
|
683
|
+
const ss = node.scrollState
|
|
684
|
+
if (!layout || !ss) return
|
|
685
|
+
|
|
686
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
|
|
687
|
+
const padding = getPadding(props)
|
|
688
|
+
// Scroll containers clip vertically (for scrolling) but NOT horizontally.
|
|
689
|
+
// Horizontal clipping is only for overflow="hidden" containers (e.g., HVL).
|
|
690
|
+
const childClipBounds = computeChildClipBounds(
|
|
691
|
+
layout,
|
|
692
|
+
props,
|
|
693
|
+
clipBounds,
|
|
694
|
+
0,
|
|
695
|
+
/* horizontal */ false,
|
|
696
|
+
/* vertical */ true,
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
// Determine if scroll offset changed since last render.
|
|
700
|
+
const scrollOffsetChanged = ss.offset !== ss.prevOffset
|
|
701
|
+
|
|
702
|
+
// Three-tier strategy for scroll container updates:
|
|
703
|
+
//
|
|
704
|
+
// 1. Buffer shift (scrollOnly): scroll offset changed but nothing else.
|
|
705
|
+
// Shift buffer contents by scroll delta, then re-render only newly
|
|
706
|
+
// visible children. Previously visible children keep their shifted pixels.
|
|
707
|
+
// This avoids re-rendering the entire viewport on every scroll.
|
|
708
|
+
//
|
|
709
|
+
// 2. Full viewport clear: children restructured or parent region changed.
|
|
710
|
+
// Must clear viewport and re-render all visible children.
|
|
711
|
+
// NOTE: subtreeDirty alone does NOT require viewport clear — dirty
|
|
712
|
+
// descendants handle their own region clearing. Clearing for subtreeDirty
|
|
713
|
+
// caused a 12ms regression (re-rendering ~50 children vs 2 dirty ones).
|
|
714
|
+
//
|
|
715
|
+
// 3. No clear needed: only subtreeDirty (some descendants changed).
|
|
716
|
+
// Children use hasPrevBuffer=true and skip via fast-path if clean.
|
|
717
|
+
//
|
|
718
|
+
// IMPORTANT: Buffer shift is unsafe when sticky children exist. Sticky
|
|
719
|
+
// children render in a second pass that overwrites first-pass content.
|
|
720
|
+
// After a shift, these overwritten pixels corrupt items at new positions
|
|
721
|
+
// that skip rendering (hasPrevBuffer=true, no dirty flags). Fall back
|
|
722
|
+
// to full viewport clear (tier 2) when sticky children are present.
|
|
723
|
+
const hasStickyChildren = !!(ss.stickyChildren && ss.stickyChildren.length > 0)
|
|
724
|
+
// Detect when visible range changed (items became hidden or newly visible).
|
|
725
|
+
// When lastVisibleChild decreases, stale pixels from now-hidden items remain
|
|
726
|
+
// in the cloned buffer and must be cleared.
|
|
727
|
+
const visibleRangeChanged =
|
|
728
|
+
ss.firstVisibleChild !== ss.prevFirstVisibleChild || ss.lastVisibleChild !== ss.prevLastVisibleChild
|
|
729
|
+
const scrollOnly =
|
|
730
|
+
hasPrevBuffer &&
|
|
731
|
+
scrollOffsetChanged &&
|
|
732
|
+
!node.childrenDirty &&
|
|
733
|
+
!parentRegionChanged &&
|
|
734
|
+
!hasStickyChildren &&
|
|
735
|
+
!visibleRangeChanged
|
|
736
|
+
const needsViewportClear =
|
|
737
|
+
hasPrevBuffer &&
|
|
738
|
+
!scrollOnly &&
|
|
739
|
+
(scrollOffsetChanged || node.childrenDirty || parentRegionChanged || visibleRangeChanged)
|
|
740
|
+
|
|
741
|
+
if (instrumentEnabled) {
|
|
742
|
+
stats.scrollContainerCount++
|
|
743
|
+
if (needsViewportClear || scrollOnly) {
|
|
744
|
+
stats.scrollViewportCleared++
|
|
745
|
+
const reasons: string[] = []
|
|
746
|
+
if (scrollOnly) reasons.push("SHIFT")
|
|
747
|
+
if (scrollOffsetChanged) reasons.push(`scrollOffset(${ss.prevOffset}->${ss.offset})`)
|
|
748
|
+
if (node.childrenDirty) reasons.push("childrenDirty")
|
|
749
|
+
if (parentRegionChanged) reasons.push("parentRegionChanged")
|
|
750
|
+
reasons.push(
|
|
751
|
+
`vp=${ss.viewportHeight} content=${ss.contentHeight} vis=${ss.firstVisibleChild}-${ss.lastVisibleChild}`,
|
|
752
|
+
)
|
|
753
|
+
stats.scrollClearReason = reasons.join("+")
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// STRICT invariant: Scroll Tier 1 (buffer shift) must never be used when sticky
|
|
758
|
+
// children exist. Sticky children render in a second pass that overwrites first-pass
|
|
759
|
+
// content — after a buffer shift, those overwritten pixels corrupt items at new
|
|
760
|
+
// positions. If this fires, the scrollOnly guard above has a logic error.
|
|
761
|
+
if (process.env.SILVERY_STRICT && scrollOnly && hasStickyChildren) {
|
|
762
|
+
throw new Error(
|
|
763
|
+
`[SILVERY_STRICT] Scroll Tier 1 (buffer shift) activated with sticky children ` +
|
|
764
|
+
`(node: ${(props.id as string | undefined) ?? node.type}, ` +
|
|
765
|
+
`stickyCount: ${ss.stickyChildren?.length ?? 0})`,
|
|
766
|
+
)
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Compute viewport geometry (shared by both paths)
|
|
770
|
+
const clearY = childClipBounds.top
|
|
771
|
+
const clearHeight = childClipBounds.bottom - childClipBounds.top
|
|
772
|
+
const contentX = layout.x + border.left + padding.left
|
|
773
|
+
const contentWidth = layout.width - border.left - border.right - padding.left - padding.right
|
|
774
|
+
const scrollBg =
|
|
775
|
+
needsViewportClear || scrollOnly
|
|
776
|
+
? props.backgroundColor
|
|
777
|
+
? parseColor(props.backgroundColor)
|
|
778
|
+
: findInheritedBg(node).color
|
|
779
|
+
: null
|
|
780
|
+
|
|
781
|
+
// Buffer shift: shift viewport contents instead of full clear.
|
|
782
|
+
// After shift, previously-visible children's pixels are at correct positions.
|
|
783
|
+
// Exposed rows (top/bottom edge) are filled with scrollBg (null = no bg).
|
|
784
|
+
const scrollDelta = ss.offset - (ss.prevOffset ?? ss.offset)
|
|
785
|
+
if (scrollOnly && clearHeight > 0) {
|
|
786
|
+
// Clear scroll indicator rows before shifting. Borderless scroll indicators
|
|
787
|
+
// (overflowIndicator) paint a full-width bar (fg=15/bg=8) on the first/last
|
|
788
|
+
// content rows. Children may be narrower than the indicator bar, so after a
|
|
789
|
+
// shift, stale indicator pixels at the edges (columns not covered by children)
|
|
790
|
+
// persist and cause incremental vs fresh render mismatches.
|
|
791
|
+
// Clearing these rows to scrollBg before the shift ensures the shift carries
|
|
792
|
+
// correct bg. The indicators are re-rendered after children by
|
|
793
|
+
// renderScrollIndicators.
|
|
794
|
+
const showBorderless = props.overflowIndicator === true
|
|
795
|
+
if (showBorderless && !border.top && !border.bottom) {
|
|
796
|
+
const topIndicatorY = clearY
|
|
797
|
+
const bottomIndicatorY = clearY + clearHeight - 1
|
|
798
|
+
if (ss.prevOffset != null && ss.prevOffset > 0) {
|
|
799
|
+
// Previous frame had items hidden above → top indicator was showing
|
|
800
|
+
buffer.fill(contentX, topIndicatorY, contentWidth, 1, { char: " ", bg: scrollBg })
|
|
801
|
+
}
|
|
802
|
+
// Previous frame had items hidden below → bottom indicator was showing
|
|
803
|
+
// (safe to always clear bottom row since it will be re-rendered)
|
|
804
|
+
buffer.fill(contentX, bottomIndicatorY, contentWidth, 1, { char: " ", bg: scrollBg })
|
|
805
|
+
}
|
|
806
|
+
buffer.scrollRegion(contentX, clearY, contentWidth, clearHeight, scrollDelta, {
|
|
807
|
+
char: " ",
|
|
808
|
+
bg: scrollBg,
|
|
809
|
+
})
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Full viewport clear (tier 2)
|
|
813
|
+
if (needsViewportClear && clearHeight > 0) {
|
|
814
|
+
buffer.fill(contentX, clearY, contentWidth, clearHeight, {
|
|
815
|
+
char: " ",
|
|
816
|
+
bg: scrollBg,
|
|
817
|
+
})
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Determine per-child hasPrev and ancestorCleared.
|
|
821
|
+
// - scrollOnly: per-child based on previous visibility
|
|
822
|
+
// - needsViewportClear: all false (full re-render)
|
|
823
|
+
// - otherwise: preserve parent's hasPrevBuffer
|
|
824
|
+
const defaultChildHasPrev = needsViewportClear ? false : hasPrevBuffer
|
|
825
|
+
const defaultChildAncestorCleared = needsViewportClear ? true : ancestorCleared || parentRegionCleared
|
|
826
|
+
|
|
827
|
+
// Propagate ancestor layout change to scroll container children.
|
|
828
|
+
const childAncestorLayoutChanged = node.layoutChangedThisFrame || !!ancestorLayoutChanged
|
|
829
|
+
|
|
830
|
+
// For buffer shift: children that were fully visible in BOTH the previous
|
|
831
|
+
// and current frames have correct pixels after the shift (childHasPrev=true).
|
|
832
|
+
// Newly visible children need full rendering (childHasPrev=false).
|
|
833
|
+
const prevVisTop = ss.prevOffset ?? ss.offset
|
|
834
|
+
const prevVisBottom = prevVisTop + ss.viewportHeight
|
|
835
|
+
|
|
836
|
+
// When sticky children exist and we're in tier 3 (subtreeDirty only, no
|
|
837
|
+
// viewport clear), force ALL first-pass items to re-render. This is needed
|
|
838
|
+
// because sticky headers render in a second pass that overwrites first-pass
|
|
839
|
+
// content. On a fresh render, the buffer has correct first-pass content at
|
|
840
|
+
// all positions. On incremental renders, the cloned buffer may have stale
|
|
841
|
+
// content from PREVIOUS frames' sticky headers at various positions — both
|
|
842
|
+
// current AND former sticky positions. Forcing all items to re-render
|
|
843
|
+
// ensures the buffer matches fresh render state before the sticky pass.
|
|
844
|
+
//
|
|
845
|
+
// Performance: this re-renders all visible items (~20-50) instead of just
|
|
846
|
+
// dirty ones (~2-3). Only applies to scroll containers with sticky children
|
|
847
|
+
// in tier 3 (not tier 1/2 which already handle this via shift/viewport clear).
|
|
848
|
+
const stickyForceRefresh = hasStickyChildren && hasPrevBuffer && !needsViewportClear
|
|
849
|
+
|
|
850
|
+
// Full viewport clear for sticky containers: clear to blank (bg=null) to
|
|
851
|
+
// match fresh buffer state. The cloned buffer has stale sticky header content
|
|
852
|
+
// from previous frames at positions that may have moved. Pre-clearing only
|
|
853
|
+
// current sticky positions is insufficient because stale bg from old sticky
|
|
854
|
+
// positions leaks through to Text nodes via inheritedBg (getCellBg is legacy fallback only).
|
|
855
|
+
// Clearing the entire viewport ensures a clean slate matching fresh render
|
|
856
|
+
// behavior (null bg everywhere before any content renders).
|
|
857
|
+
//
|
|
858
|
+
// Uses bg=null (not scrollBg/inherited bg) because fresh render starts with
|
|
859
|
+
// a blank buffer — the viewport has null bg before any content renders.
|
|
860
|
+
if (stickyForceRefresh && clearHeight > 0) {
|
|
861
|
+
buffer.fill(contentX, clearY, contentWidth, clearHeight, { char: " ", bg: null })
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// First pass: render non-sticky visible children with scroll offset
|
|
865
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
866
|
+
const child = node.children[i]
|
|
867
|
+
if (!child) continue
|
|
868
|
+
const childProps = child.props as BoxProps
|
|
869
|
+
|
|
870
|
+
// Skip sticky children - they're rendered in second pass
|
|
871
|
+
if (childProps.position === "sticky") {
|
|
872
|
+
continue
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// Skip children that are completely outside the visible range
|
|
876
|
+
if (i < ss.firstVisibleChild || i > ss.lastVisibleChild) {
|
|
877
|
+
continue
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// Determine per-child hasPrev for buffer shift mode
|
|
881
|
+
let thisChildHasPrev = defaultChildHasPrev
|
|
882
|
+
let thisChildAncestorCleared = defaultChildAncestorCleared
|
|
883
|
+
if (scrollOnly) {
|
|
884
|
+
// Check if child was fully visible in the previous frame
|
|
885
|
+
const childRect = child.contentRect
|
|
886
|
+
if (childRect) {
|
|
887
|
+
const childTop = childRect.y - layout.y - border.top - padding.top
|
|
888
|
+
const childBottom = childTop + childRect.height
|
|
889
|
+
const wasFullyVisible = childTop >= prevVisTop && childBottom <= prevVisBottom
|
|
890
|
+
thisChildHasPrev = wasFullyVisible
|
|
891
|
+
// Shifted children: their pixels are intact (not cleared)
|
|
892
|
+
// Newly visible: exposed region was filled by scrollRegion
|
|
893
|
+
thisChildAncestorCleared = wasFullyVisible ? ancestorCleared || parentRegionCleared : true
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Force fresh rendering when sticky children exist (see stickyForceRefresh).
|
|
898
|
+
if (stickyForceRefresh && thisChildHasPrev) {
|
|
899
|
+
thisChildHasPrev = false
|
|
900
|
+
thisChildAncestorCleared = false
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// Render visible children with scroll offset applied.
|
|
904
|
+
renderNodeToBuffer(
|
|
905
|
+
child,
|
|
906
|
+
buffer,
|
|
907
|
+
{
|
|
908
|
+
scrollOffset: ss.offset,
|
|
909
|
+
clipBounds: childClipBounds,
|
|
910
|
+
hasPrevBuffer: thisChildHasPrev,
|
|
911
|
+
ancestorCleared: thisChildAncestorCleared,
|
|
912
|
+
bufferIsCloned,
|
|
913
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
914
|
+
},
|
|
915
|
+
ctx,
|
|
916
|
+
)
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// Second pass: render sticky children at their computed positions
|
|
920
|
+
// Rendered last so they appear on top of other content
|
|
921
|
+
if (ss.stickyChildren) {
|
|
922
|
+
for (const sticky of ss.stickyChildren) {
|
|
923
|
+
const child = node.children[sticky.index]
|
|
924
|
+
if (!child?.contentRect) continue
|
|
925
|
+
|
|
926
|
+
// Calculate the scroll offset that would place the child at its sticky position
|
|
927
|
+
// stickyOffset = naturalTop - renderOffset
|
|
928
|
+
// This makes the child render at renderOffset instead of its natural position
|
|
929
|
+
const stickyScrollOffset = sticky.naturalTop - sticky.renderOffset
|
|
930
|
+
|
|
931
|
+
// Sticky children always re-render (hasPrevBuffer=false) since their
|
|
932
|
+
// effective scroll offset may change even when the container's doesn't.
|
|
933
|
+
//
|
|
934
|
+
// ancestorCleared=false matches fresh render semantics: on a fresh render,
|
|
935
|
+
// the buffer at sticky positions has first-pass content (not "cleared").
|
|
936
|
+
// Using ancestorCleared=true would cause transparent spacer Boxes to clear
|
|
937
|
+
// their region (via layoutChanged=true from prevLayout=null → cascading
|
|
938
|
+
// parentRegionCleared), wiping overlapping sticky headers rendered earlier
|
|
939
|
+
// in this pass.
|
|
940
|
+
//
|
|
941
|
+
// Stale bg from previous frames is handled by the stickyForceRefresh
|
|
942
|
+
// pre-clear above, which ensures correct bg is in the buffer before sticky
|
|
943
|
+
// children render on top of first-pass content.
|
|
944
|
+
renderNodeToBuffer(
|
|
945
|
+
child,
|
|
946
|
+
buffer,
|
|
947
|
+
{
|
|
948
|
+
scrollOffset: stickyScrollOffset,
|
|
949
|
+
clipBounds: childClipBounds,
|
|
950
|
+
hasPrevBuffer: false,
|
|
951
|
+
ancestorCleared: false,
|
|
952
|
+
bufferIsCloned,
|
|
953
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
954
|
+
},
|
|
955
|
+
ctx,
|
|
956
|
+
)
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Render children of a normal (non-scroll) container.
|
|
963
|
+
*/
|
|
964
|
+
function renderNormalChildren(
|
|
965
|
+
node: TeaNode,
|
|
966
|
+
buffer: TerminalBuffer,
|
|
967
|
+
props: BoxProps,
|
|
968
|
+
nodeState: NodeRenderState,
|
|
969
|
+
childPositionChanged = false,
|
|
970
|
+
parentRegionCleared = false,
|
|
971
|
+
parentRegionChanged = false,
|
|
972
|
+
ctx?: PipelineContext,
|
|
973
|
+
): void {
|
|
974
|
+
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged } = nodeState
|
|
975
|
+
// Resolve instrumentation from ctx or module globals
|
|
976
|
+
const instrumentEnabled = ctx?.instrumentEnabled ?? _instrumentEnabled
|
|
977
|
+
const stats = ctx?.stats ?? _contentPhaseStats
|
|
978
|
+
const layout = node.contentRect
|
|
979
|
+
if (!layout) return
|
|
980
|
+
|
|
981
|
+
// For overflow='hidden' containers, clip children to content area.
|
|
982
|
+
// Supports per-axis clipping: overflowX/overflowY override the shorthand overflow prop.
|
|
983
|
+
const clipX = (props.overflowX ?? props.overflow) === "hidden"
|
|
984
|
+
const clipY = (props.overflowY ?? props.overflow) === "hidden"
|
|
985
|
+
const effectiveClipBounds =
|
|
986
|
+
clipX || clipY ? computeChildClipBounds(layout, props, clipBounds, scrollOffset, clipX, clipY) : clipBounds
|
|
987
|
+
|
|
988
|
+
// Non-scroll sticky children support. When the layout phase computes
|
|
989
|
+
// node.stickyChildren, we use the same two-pass pattern as scroll containers:
|
|
990
|
+
// first pass renders non-sticky children, second pass renders sticky children
|
|
991
|
+
// at their computed renderOffset positions.
|
|
992
|
+
const hasStickyChildren = !!(node.stickyChildren && node.stickyChildren.length > 0)
|
|
993
|
+
|
|
994
|
+
// When sticky children exist and hasPrevBuffer is true, force all first-pass
|
|
995
|
+
// children to re-render. The cloned buffer may have stale pixels from previous
|
|
996
|
+
// frames' sticky positions. This matches the stickyForceRefresh pattern from
|
|
997
|
+
// scroll containers (Tier 3).
|
|
998
|
+
const stickyForceRefresh = hasStickyChildren && hasPrevBuffer
|
|
999
|
+
|
|
1000
|
+
// Pre-clear the content area to bg=null when stickyForceRefresh is true.
|
|
1001
|
+
// Fresh renders start with a blank buffer (null bg everywhere). The cloned
|
|
1002
|
+
// buffer has stale content from old sticky positions that would leak through
|
|
1003
|
+
// on incremental renders. Clearing to null matches fresh render state before
|
|
1004
|
+
// any content renders.
|
|
1005
|
+
if (stickyForceRefresh) {
|
|
1006
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
|
|
1007
|
+
const padding = getPadding(props)
|
|
1008
|
+
let clearX = layout.x + border.left + padding.left
|
|
1009
|
+
let clearY = layout.y - scrollOffset + border.top + padding.top
|
|
1010
|
+
let clearW = layout.width - border.left - border.right - padding.left - padding.right
|
|
1011
|
+
let clearH = layout.height - border.top - border.bottom - padding.top - padding.bottom
|
|
1012
|
+
// Clip to clipBounds (same discipline as scroll container clear)
|
|
1013
|
+
if (clipBounds) {
|
|
1014
|
+
const clipTop = clipBounds.top
|
|
1015
|
+
const clipBottom = clipBounds.bottom
|
|
1016
|
+
if (clearY < clipTop) {
|
|
1017
|
+
clearH -= clipTop - clearY
|
|
1018
|
+
clearY = clipTop
|
|
1019
|
+
}
|
|
1020
|
+
if (clearY + clearH > clipBottom) {
|
|
1021
|
+
clearH = clipBottom - clearY
|
|
1022
|
+
}
|
|
1023
|
+
if (clipBounds.left !== undefined && clearX < clipBounds.left) {
|
|
1024
|
+
clearW -= clipBounds.left - clearX
|
|
1025
|
+
clearX = clipBounds.left
|
|
1026
|
+
}
|
|
1027
|
+
if (clipBounds.right !== undefined && clearX + clearW > clipBounds.right) {
|
|
1028
|
+
clearW = clipBounds.right - clearX
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
if (clearW > 0 && clearH > 0) {
|
|
1032
|
+
buffer.fill(clearX, clearY, clearW, clearH, { char: " ", bg: null })
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// Force children to re-render when parent's region was modified on a clone,
|
|
1037
|
+
// children were restructured, or sibling positions shifted.
|
|
1038
|
+
const childrenNeedRepaint = node.childrenDirty || childPositionChanged || parentRegionChanged
|
|
1039
|
+
if (instrumentEnabled && childrenNeedRepaint && hasPrevBuffer) {
|
|
1040
|
+
stats.normalChildrenRepaint++
|
|
1041
|
+
const reasons: string[] = []
|
|
1042
|
+
if (node.childrenDirty) reasons.push("childrenDirty")
|
|
1043
|
+
if (childPositionChanged) reasons.push("childPositionChanged")
|
|
1044
|
+
if (parentRegionChanged) reasons.push("parentRegionChanged")
|
|
1045
|
+
stats.normalRepaintReason = reasons.join("+")
|
|
1046
|
+
}
|
|
1047
|
+
let childHasPrev = childrenNeedRepaint ? false : hasPrevBuffer
|
|
1048
|
+
// childAncestorCleared: tells descendants that STALE pixels exist in the buffer.
|
|
1049
|
+
// Only parentRegionCleared (no bg fill → stale pixels remain) propagates this.
|
|
1050
|
+
// parentRegionChanged WITHOUT parentRegionCleared means the parent filled its bg,
|
|
1051
|
+
// so children's positions have correct bg — NOT stale. Setting ancestorCleared
|
|
1052
|
+
// there would cause children to re-fill, overwriting border cells at boundaries.
|
|
1053
|
+
// When this node has backgroundColor, its renderBox fill covers any stale
|
|
1054
|
+
// pixels from ancestor clears — so children don't need ancestorCleared.
|
|
1055
|
+
let childAncestorCleared = parentRegionCleared || (ancestorCleared && !props.backgroundColor)
|
|
1056
|
+
|
|
1057
|
+
// Propagate ancestor layout change to children: if this node or any ancestor
|
|
1058
|
+
// had layoutChangedThisFrame, children must not be skipped even if their own
|
|
1059
|
+
// flags are clean — their pixels in the cloned buffer are at wrong positions.
|
|
1060
|
+
const childAncestorLayoutChanged = node.layoutChangedThisFrame || !!ancestorLayoutChanged
|
|
1061
|
+
|
|
1062
|
+
// Override child flags when sticky force refresh is active — all first-pass
|
|
1063
|
+
// children must re-render fresh (matching the scroll container pattern).
|
|
1064
|
+
if (stickyForceRefresh) {
|
|
1065
|
+
childHasPrev = false
|
|
1066
|
+
childAncestorCleared = false
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// Multi-pass rendering to match CSS paint order:
|
|
1070
|
+
// 1. Normal-flow children (skip sticky and absolute)
|
|
1071
|
+
// 2. Sticky children at computed positions (on top of normal-flow)
|
|
1072
|
+
// 3. Absolute children on top of everything
|
|
1073
|
+
//
|
|
1074
|
+
// This ensures absolute children's pixels (bg fills, text) are never
|
|
1075
|
+
// overwritten by normal-flow siblings' clearNodeRegion/render.
|
|
1076
|
+
//
|
|
1077
|
+
// Pre-scan: detect if any non-absolute, non-sticky sibling is dirty. When
|
|
1078
|
+
// true, absolute children in the third pass must force-repaint because the
|
|
1079
|
+
// first pass may have overwritten their pixels in the cloned buffer.
|
|
1080
|
+
let hasAbsoluteChildren = false
|
|
1081
|
+
|
|
1082
|
+
// First pass: render normal-flow children (skip sticky + absolute), track dirty state
|
|
1083
|
+
for (const child of node.children) {
|
|
1084
|
+
const childProps = child.props as BoxProps
|
|
1085
|
+
if (childProps.position === "absolute") {
|
|
1086
|
+
hasAbsoluteChildren = true
|
|
1087
|
+
continue // Skip — rendered in third pass
|
|
1088
|
+
}
|
|
1089
|
+
if (hasStickyChildren && childProps.position === "sticky") {
|
|
1090
|
+
continue // Skip — rendered in second pass
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
renderNodeToBuffer(
|
|
1094
|
+
child,
|
|
1095
|
+
buffer,
|
|
1096
|
+
{
|
|
1097
|
+
scrollOffset,
|
|
1098
|
+
clipBounds: effectiveClipBounds,
|
|
1099
|
+
hasPrevBuffer: childHasPrev,
|
|
1100
|
+
ancestorCleared: childAncestorCleared,
|
|
1101
|
+
bufferIsCloned,
|
|
1102
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
1103
|
+
},
|
|
1104
|
+
ctx,
|
|
1105
|
+
)
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
// Second pass: render sticky children at their computed positions.
|
|
1109
|
+
// Rendered after normal-flow so they appear on top of other content.
|
|
1110
|
+
if (node.stickyChildren) {
|
|
1111
|
+
for (const sticky of node.stickyChildren) {
|
|
1112
|
+
const child = node.children[sticky.index]
|
|
1113
|
+
if (!child?.contentRect) continue
|
|
1114
|
+
|
|
1115
|
+
// Calculate the scroll offset that would place the child at its sticky position.
|
|
1116
|
+
// stickyScrollOffset = naturalTop - renderOffset
|
|
1117
|
+
// This makes the child render at renderOffset instead of its natural position.
|
|
1118
|
+
const stickyScrollOffset = sticky.naturalTop - sticky.renderOffset
|
|
1119
|
+
|
|
1120
|
+
// Sticky children always re-render (hasPrevBuffer=false) since their
|
|
1121
|
+
// effective position may change between frames.
|
|
1122
|
+
//
|
|
1123
|
+
// ancestorCleared=false matches fresh render semantics: on a fresh render,
|
|
1124
|
+
// the buffer at sticky positions has first-pass content (not "cleared").
|
|
1125
|
+
// Using ancestorCleared=true would cause transparent spacer Boxes to clear
|
|
1126
|
+
// their region, wiping overlapping sticky headers rendered earlier in this pass.
|
|
1127
|
+
//
|
|
1128
|
+
// ancestorLayoutChanged propagated so descendants know to re-render.
|
|
1129
|
+
renderNodeToBuffer(
|
|
1130
|
+
child,
|
|
1131
|
+
buffer,
|
|
1132
|
+
{
|
|
1133
|
+
scrollOffset: stickyScrollOffset,
|
|
1134
|
+
clipBounds: effectiveClipBounds,
|
|
1135
|
+
hasPrevBuffer: false,
|
|
1136
|
+
ancestorCleared: false,
|
|
1137
|
+
bufferIsCloned,
|
|
1138
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
1139
|
+
},
|
|
1140
|
+
ctx,
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// Third pass: render absolute children on top (CSS paint order)
|
|
1146
|
+
if (hasAbsoluteChildren) {
|
|
1147
|
+
for (const child of node.children) {
|
|
1148
|
+
const childProps = child.props as BoxProps
|
|
1149
|
+
if (childProps.position !== "absolute") continue
|
|
1150
|
+
|
|
1151
|
+
// Both hasPrevBuffer and ancestorCleared must be false for absolute children
|
|
1152
|
+
// in the second pass. The buffer at the absolute child's position contains
|
|
1153
|
+
// first-pass content (normal-flow siblings), not "previous frame" content.
|
|
1154
|
+
// This is conceptually a fresh render at the absolute child's position:
|
|
1155
|
+
//
|
|
1156
|
+
// - hasPrevBuffer=false: prevents parentRegionCleared from firing.
|
|
1157
|
+
// Without this, a transparent overlay (no backgroundColor) that changes
|
|
1158
|
+
// (contentAreaAffected=true) would clear its entire region, wiping the
|
|
1159
|
+
// normal-flow content painted in the first pass. On a fresh render,
|
|
1160
|
+
// hasPrevBuffer=false prevents clearing, so this matches.
|
|
1161
|
+
//
|
|
1162
|
+
// - ancestorCleared=false: prevents transparent descendants from clearing
|
|
1163
|
+
// their regions, which would also wipe first-pass content.
|
|
1164
|
+
renderNodeToBuffer(
|
|
1165
|
+
child,
|
|
1166
|
+
buffer,
|
|
1167
|
+
{
|
|
1168
|
+
scrollOffset,
|
|
1169
|
+
clipBounds: effectiveClipBounds,
|
|
1170
|
+
hasPrevBuffer: false,
|
|
1171
|
+
ancestorCleared: false,
|
|
1172
|
+
bufferIsCloned,
|
|
1173
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
1174
|
+
},
|
|
1175
|
+
ctx,
|
|
1176
|
+
)
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
// ============================================================================
|
|
1182
|
+
// Helpers
|
|
1183
|
+
// ============================================================================
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Clear dirty flags on the current node only (no recursion).
|
|
1187
|
+
* Used after rendering a node to reset its flags.
|
|
1188
|
+
*/
|
|
1189
|
+
function clearNodeDirtyFlags(node: TeaNode): void {
|
|
1190
|
+
node.contentDirty = false
|
|
1191
|
+
node.paintDirty = false
|
|
1192
|
+
node.bgDirty = false
|
|
1193
|
+
node.subtreeDirty = false
|
|
1194
|
+
node.childrenDirty = false
|
|
1195
|
+
node.layoutChangedThisFrame = false
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
/**
|
|
1199
|
+
* Clear dirty flags on a subtree that was skipped during incremental rendering.
|
|
1200
|
+
*/
|
|
1201
|
+
function clearDirtyFlags(node: TeaNode): void {
|
|
1202
|
+
clearNodeDirtyFlags(node)
|
|
1203
|
+
for (const child of node.children) {
|
|
1204
|
+
if (child.layoutNode) {
|
|
1205
|
+
clearDirtyFlags(child)
|
|
1206
|
+
} else {
|
|
1207
|
+
// Virtual text children also need flags cleared — they're rendered by
|
|
1208
|
+
// their parent's collectTextContent(), not by renderNodeToBuffer().
|
|
1209
|
+
clearVirtualTextFlags(child)
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
/**
|
|
1215
|
+
* Clear dirty flags on a virtual text node and its descendants.
|
|
1216
|
+
* Virtual text nodes (no layoutNode) are rendered by their parent layout
|
|
1217
|
+
* ancestor via collectTextContent(). Their dirty flags must be cleared
|
|
1218
|
+
* after the parent renders, otherwise stale subtreeDirty blocks
|
|
1219
|
+
* markSubtreeDirty() propagation on future updates.
|
|
1220
|
+
*/
|
|
1221
|
+
function clearVirtualTextFlags(node: TeaNode): void {
|
|
1222
|
+
clearNodeDirtyFlags(node)
|
|
1223
|
+
for (const child of node.children) {
|
|
1224
|
+
clearVirtualTextFlags(child)
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
/**
|
|
1229
|
+
* Check if any child's position changed since last render (sibling shift).
|
|
1230
|
+
* Checked even when subtreeDirty=true because subtreeDirty only means
|
|
1231
|
+
* descendants are dirty, not that this container's gap regions need clearing.
|
|
1232
|
+
*/
|
|
1233
|
+
function hasChildPositionChanged(node: TeaNode): boolean {
|
|
1234
|
+
for (const child of node.children) {
|
|
1235
|
+
if (child.contentRect && child.prevLayout) {
|
|
1236
|
+
if (child.contentRect.x !== child.prevLayout.x || child.contentRect.y !== child.prevLayout.y) {
|
|
1237
|
+
return true
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
return false
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
/**
|
|
1245
|
+
* Check if any descendant was overflowing THIS node's rect and had its layout change.
|
|
1246
|
+
* Recursive: a grandchild overflowing a child AND this node is detected here.
|
|
1247
|
+
*
|
|
1248
|
+
* When a descendant overflows (prevLayout extends beyond this node's rect) and then
|
|
1249
|
+
* shrinks, clearExcessArea on the descendant clips to its immediate parent's content
|
|
1250
|
+
* area, leaving stale pixels in this node's border/padding area and beyond this node's
|
|
1251
|
+
* rect. By detecting at THIS level, the node clears its region (restoring borders)
|
|
1252
|
+
* and handles overflow beyond its rect via clearDescendantOverflowRegions.
|
|
1253
|
+
*
|
|
1254
|
+
* Only follows subtreeDirty paths for efficiency — layoutChangedThisFrame on a
|
|
1255
|
+
* descendant implies subtreeDirty on all its ancestors.
|
|
1256
|
+
*/
|
|
1257
|
+
function hasDescendantOverflowChanged(node: TeaNode): boolean {
|
|
1258
|
+
const rect = node.contentRect!
|
|
1259
|
+
return _checkDescendantOverflow(node.children, rect.x, rect.y, rect.x + rect.width, rect.y + rect.height)
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
function _checkDescendantOverflow(
|
|
1263
|
+
children: readonly TeaNode[],
|
|
1264
|
+
nodeLeft: number,
|
|
1265
|
+
nodeTop: number,
|
|
1266
|
+
nodeRight: number,
|
|
1267
|
+
nodeBottom: number,
|
|
1268
|
+
): boolean {
|
|
1269
|
+
for (const child of children) {
|
|
1270
|
+
// Check this child's previous layout against the ancestor's rect
|
|
1271
|
+
if (child.prevLayout && child.layoutChangedThisFrame) {
|
|
1272
|
+
const prev = child.prevLayout
|
|
1273
|
+
if (
|
|
1274
|
+
prev.x + prev.width > nodeRight ||
|
|
1275
|
+
prev.y + prev.height > nodeBottom ||
|
|
1276
|
+
prev.x < nodeLeft ||
|
|
1277
|
+
prev.y < nodeTop
|
|
1278
|
+
) {
|
|
1279
|
+
return true
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
// Recurse into subtree-dirty children to find deeper overflows
|
|
1283
|
+
if (child.subtreeDirty && child.children !== undefined) {
|
|
1284
|
+
if (_checkDescendantOverflow(child.children, nodeLeft, nodeTop, nodeRight, nodeBottom)) {
|
|
1285
|
+
return true
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
return false
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* Compute clip bounds for a container's children by insetting for border+padding,
|
|
1294
|
+
* then intersecting with parent clip bounds.
|
|
1295
|
+
*/
|
|
1296
|
+
function computeChildClipBounds(
|
|
1297
|
+
layout: NonNullable<TeaNode["contentRect"]>,
|
|
1298
|
+
props: BoxProps,
|
|
1299
|
+
parentClip: ClipBounds | undefined,
|
|
1300
|
+
scrollOffset = 0,
|
|
1301
|
+
/** Compute left/right clip bounds for horizontal overflow clipping. */
|
|
1302
|
+
horizontal = true,
|
|
1303
|
+
/** Compute top/bottom clip bounds for vertical overflow clipping.
|
|
1304
|
+
* Defaults to true — scroll containers pass vertical=true, horizontal=false. */
|
|
1305
|
+
vertical = true,
|
|
1306
|
+
): ClipBounds {
|
|
1307
|
+
const border = props.borderStyle ? getBorderSize(props) : { top: 0, bottom: 0, left: 0, right: 0 }
|
|
1308
|
+
const padding = getPadding(props)
|
|
1309
|
+
const adjustedY = layout.y - scrollOffset
|
|
1310
|
+
const nodeClip: ClipBounds = vertical
|
|
1311
|
+
? {
|
|
1312
|
+
top: adjustedY + border.top + padding.top,
|
|
1313
|
+
bottom: adjustedY + layout.height - border.bottom - padding.bottom,
|
|
1314
|
+
}
|
|
1315
|
+
: { top: -Infinity, bottom: Infinity }
|
|
1316
|
+
if (horizontal) {
|
|
1317
|
+
nodeClip.left = layout.x + border.left + padding.left
|
|
1318
|
+
nodeClip.right = layout.x + layout.width - border.right - padding.right
|
|
1319
|
+
}
|
|
1320
|
+
if (!parentClip) return nodeClip
|
|
1321
|
+
const result: ClipBounds = {
|
|
1322
|
+
top: vertical ? Math.max(parentClip.top, nodeClip.top) : parentClip.top,
|
|
1323
|
+
bottom: vertical ? Math.min(parentClip.bottom, nodeClip.bottom) : parentClip.bottom,
|
|
1324
|
+
}
|
|
1325
|
+
if (horizontal && nodeClip.left !== undefined && nodeClip.right !== undefined) {
|
|
1326
|
+
result.left = Math.max(parentClip.left ?? 0, nodeClip.left)
|
|
1327
|
+
result.right = Math.min(parentClip.right ?? Infinity, nodeClip.right)
|
|
1328
|
+
} else if (parentClip.left !== undefined && parentClip.right !== undefined) {
|
|
1329
|
+
// Pass through parent's horizontal clip bounds without adding own
|
|
1330
|
+
result.left = parentClip.left
|
|
1331
|
+
result.right = parentClip.right
|
|
1332
|
+
}
|
|
1333
|
+
return result
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// ============================================================================
|
|
1337
|
+
// Region Clearing
|
|
1338
|
+
// ============================================================================
|
|
1339
|
+
|
|
1340
|
+
/**
|
|
1341
|
+
* Result of finding inherited background - includes both color and ancestor bounds.
|
|
1342
|
+
*/
|
|
1343
|
+
interface InheritedBgResult {
|
|
1344
|
+
color: Color
|
|
1345
|
+
/** The rect of the ancestor that has the background color (for clipping) */
|
|
1346
|
+
ancestorRect: { x: number; y: number; width: number; height: number } | null
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
/**
|
|
1350
|
+
* Find the nearest ancestor with a backgroundColor and return the parsed color
|
|
1351
|
+
* along with the ancestor's rect for proper clipping.
|
|
1352
|
+
*
|
|
1353
|
+
* When clearing excess area after a node shrinks, we need to clip to the colored
|
|
1354
|
+
* ancestor's bounds - not just the immediate parent. Otherwise the inherited
|
|
1355
|
+
* color can bleed into sibling areas that should have different backgrounds.
|
|
1356
|
+
*/
|
|
1357
|
+
function findInheritedBg(node: TeaNode): InheritedBgResult {
|
|
1358
|
+
let current = node.parent
|
|
1359
|
+
while (current) {
|
|
1360
|
+
const bg = (current.props as BoxProps).backgroundColor
|
|
1361
|
+
if (bg) {
|
|
1362
|
+
return {
|
|
1363
|
+
color: parseColor(bg),
|
|
1364
|
+
ancestorRect: current.contentRect,
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
current = current.parent
|
|
1368
|
+
}
|
|
1369
|
+
return { color: null, ancestorRect: null }
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Find the nearest ancestor Box with a `color` prop and return the parsed color.
|
|
1374
|
+
* Implements CSS-style foreground color inheritance: Text children without an
|
|
1375
|
+
* explicit `color` prop inherit from the nearest Box ancestor that sets one.
|
|
1376
|
+
*/
|
|
1377
|
+
function findInheritedFg(node: TeaNode): Color {
|
|
1378
|
+
let current = node.parent
|
|
1379
|
+
while (current) {
|
|
1380
|
+
const fg = (current.props as BoxProps).color
|
|
1381
|
+
if (fg) return parseColor(fg)
|
|
1382
|
+
current = current.parent
|
|
1383
|
+
}
|
|
1384
|
+
return null
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/**
|
|
1388
|
+
* Clear overflow regions: areas where children's prevLayouts extended beyond
|
|
1389
|
+
* this node's rect. Called when childOverflowChanged detected stale overflow.
|
|
1390
|
+
*
|
|
1391
|
+
* clearNodeRegion handles the node's own rect. This function handles the
|
|
1392
|
+
* overflow area — pixels that a child rendered OUTSIDE the parent's rect
|
|
1393
|
+
* in a previous frame (via overflow:visible behavior). When the child shrinks,
|
|
1394
|
+
* those pixels become stale in the cloned buffer.
|
|
1395
|
+
*
|
|
1396
|
+
* Clears each child's overflow extent, clipped to buffer bounds.
|
|
1397
|
+
*/
|
|
1398
|
+
/**
|
|
1399
|
+
* Clear areas where descendants' previous layouts overflowed beyond THIS node's rect.
|
|
1400
|
+
* Only clears OUTSIDE the node's rect — interior clearing is handled by clearNodeRegion
|
|
1401
|
+
* and renderBox. Recursive: follows subtreeDirty paths to find all overflowing descendants.
|
|
1402
|
+
*/
|
|
1403
|
+
function clearDescendantOverflowRegions(
|
|
1404
|
+
node: TeaNode,
|
|
1405
|
+
buffer: TerminalBuffer,
|
|
1406
|
+
layout: NonNullable<TeaNode["contentRect"]>,
|
|
1407
|
+
scrollOffset: number,
|
|
1408
|
+
clipBounds: ClipBounds | undefined,
|
|
1409
|
+
): void {
|
|
1410
|
+
const inherited = findInheritedBg(node)
|
|
1411
|
+
const clearBg = inherited.color
|
|
1412
|
+
const nodeRight = layout.x + layout.width
|
|
1413
|
+
const nodeBottom = layout.y - scrollOffset + layout.height
|
|
1414
|
+
const nodeLeft = layout.x
|
|
1415
|
+
const nodeTop = layout.y - scrollOffset
|
|
1416
|
+
|
|
1417
|
+
_clearDescendantOverflow(
|
|
1418
|
+
node.children,
|
|
1419
|
+
buffer,
|
|
1420
|
+
nodeLeft,
|
|
1421
|
+
nodeTop,
|
|
1422
|
+
nodeRight,
|
|
1423
|
+
nodeBottom,
|
|
1424
|
+
scrollOffset,
|
|
1425
|
+
clipBounds,
|
|
1426
|
+
clearBg,
|
|
1427
|
+
)
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function _clearDescendantOverflow(
|
|
1431
|
+
children: readonly TeaNode[],
|
|
1432
|
+
buffer: TerminalBuffer,
|
|
1433
|
+
nodeLeft: number,
|
|
1434
|
+
nodeTop: number,
|
|
1435
|
+
nodeRight: number,
|
|
1436
|
+
nodeBottom: number,
|
|
1437
|
+
scrollOffset: number,
|
|
1438
|
+
clipBounds: ClipBounds | undefined,
|
|
1439
|
+
clearBg: Color,
|
|
1440
|
+
): void {
|
|
1441
|
+
for (const child of children) {
|
|
1442
|
+
if (child.prevLayout && child.layoutChangedThisFrame) {
|
|
1443
|
+
const prev = child.prevLayout
|
|
1444
|
+
const prevRight = prev.x + prev.width
|
|
1445
|
+
const prevBottom = prev.y - scrollOffset + prev.height
|
|
1446
|
+
const prevTop = prev.y - scrollOffset
|
|
1447
|
+
|
|
1448
|
+
// Clear overflow to the right of the ancestor
|
|
1449
|
+
if (prevRight > nodeRight) {
|
|
1450
|
+
const overflowX = nodeRight
|
|
1451
|
+
const overflowWidth = Math.min(prevRight, buffer.width) - overflowX
|
|
1452
|
+
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0)
|
|
1453
|
+
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height)
|
|
1454
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) {
|
|
1455
|
+
buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
// Clear overflow below the ancestor
|
|
1459
|
+
if (prevBottom > nodeBottom) {
|
|
1460
|
+
const overflowTop = Math.max(nodeBottom, clipBounds?.top ?? 0)
|
|
1461
|
+
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height)
|
|
1462
|
+
const overflowX = Math.max(prev.x, clipBounds?.left ?? 0)
|
|
1463
|
+
const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX
|
|
1464
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) {
|
|
1465
|
+
buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
|
|
1466
|
+
}
|
|
1467
|
+
}
|
|
1468
|
+
// Clear overflow to the left of the ancestor
|
|
1469
|
+
if (prev.x < nodeLeft) {
|
|
1470
|
+
const overflowX = Math.max(prev.x, 0)
|
|
1471
|
+
const overflowWidth = Math.min(nodeLeft, buffer.width) - overflowX
|
|
1472
|
+
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0)
|
|
1473
|
+
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height)
|
|
1474
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) {
|
|
1475
|
+
buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
// Clear overflow above the ancestor
|
|
1479
|
+
if (prevTop < nodeTop) {
|
|
1480
|
+
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0)
|
|
1481
|
+
const overflowBottom = Math.min(nodeTop, clipBounds?.bottom ?? buffer.height)
|
|
1482
|
+
const overflowX = Math.max(prev.x, clipBounds?.left ?? 0)
|
|
1483
|
+
const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX
|
|
1484
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) {
|
|
1485
|
+
buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, { char: " ", bg: clearBg })
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
// Recurse into subtree-dirty children to find deeper overflows
|
|
1490
|
+
if (child.subtreeDirty && child.children !== undefined) {
|
|
1491
|
+
_clearDescendantOverflow(
|
|
1492
|
+
child.children,
|
|
1493
|
+
buffer,
|
|
1494
|
+
nodeLeft,
|
|
1495
|
+
nodeTop,
|
|
1496
|
+
nodeRight,
|
|
1497
|
+
nodeBottom,
|
|
1498
|
+
scrollOffset,
|
|
1499
|
+
clipBounds,
|
|
1500
|
+
clearBg,
|
|
1501
|
+
)
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
}
|
|
1505
|
+
|
|
1506
|
+
/**
|
|
1507
|
+
* Clear a node's region with inherited bg when it has no backgroundColor.
|
|
1508
|
+
* Also clears excess area when the node shrank (previous layout was larger).
|
|
1509
|
+
*
|
|
1510
|
+
* Clipping: clips to parent's contentRect (prevents overflow) and to the
|
|
1511
|
+
* colored ancestor's bounds (prevents bg color bleeding into siblings).
|
|
1512
|
+
*/
|
|
1513
|
+
function clearNodeRegion(
|
|
1514
|
+
node: TeaNode,
|
|
1515
|
+
buffer: TerminalBuffer,
|
|
1516
|
+
layout: NonNullable<TeaNode["contentRect"]>,
|
|
1517
|
+
scrollOffset: number,
|
|
1518
|
+
clipBounds: ClipBounds | undefined,
|
|
1519
|
+
layoutChanged: boolean,
|
|
1520
|
+
): void {
|
|
1521
|
+
const inherited = findInheritedBg(node)
|
|
1522
|
+
const clearBg = inherited.color
|
|
1523
|
+
const screenY = layout.y - scrollOffset
|
|
1524
|
+
|
|
1525
|
+
// Clip to parent's contentRect to prevent oversized children from clearing
|
|
1526
|
+
// beyond their parent's bounds and bleeding inherited bg into sibling regions.
|
|
1527
|
+
const parentRect = node.parent?.contentRect
|
|
1528
|
+
const parentBottom = parentRect ? parentRect.y - scrollOffset + parentRect.height : undefined
|
|
1529
|
+
|
|
1530
|
+
const clearY = clipBounds ? Math.max(screenY, clipBounds.top) : screenY
|
|
1531
|
+
let clearBottom = clipBounds ? Math.min(screenY + layout.height, clipBounds.bottom) : screenY + layout.height
|
|
1532
|
+
if (parentBottom !== undefined) {
|
|
1533
|
+
clearBottom = Math.min(clearBottom, parentBottom)
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// Clip horizontally to clipBounds (overflow:hidden containers) and to the
|
|
1537
|
+
// colored ancestor's bounds (prevents inherited bg bleeding into siblings).
|
|
1538
|
+
let clearX = layout.x
|
|
1539
|
+
let clearWidth = layout.width
|
|
1540
|
+
if (clipBounds?.left !== undefined && clipBounds.right !== undefined) {
|
|
1541
|
+
if (clearX < clipBounds.left) {
|
|
1542
|
+
clearWidth -= clipBounds.left - clearX
|
|
1543
|
+
clearX = clipBounds.left
|
|
1544
|
+
}
|
|
1545
|
+
if (clearX + clearWidth > clipBounds.right) {
|
|
1546
|
+
clearWidth = Math.max(0, clipBounds.right - clearX)
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
if (inherited.ancestorRect) {
|
|
1550
|
+
const ancestorRight = inherited.ancestorRect.x + inherited.ancestorRect.width
|
|
1551
|
+
const ancestorLeft = inherited.ancestorRect.x
|
|
1552
|
+
if (clearX < ancestorLeft) {
|
|
1553
|
+
clearWidth -= ancestorLeft - clearX
|
|
1554
|
+
clearX = ancestorLeft
|
|
1555
|
+
}
|
|
1556
|
+
if (clearX + clearWidth > ancestorRight) {
|
|
1557
|
+
clearWidth = Math.max(0, ancestorRight - clearX)
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
const clearHeight = clearBottom - clearY
|
|
1562
|
+
if (clearHeight > 0 && clearWidth > 0) {
|
|
1563
|
+
// Cell debug: log clearNodeRegion coverage
|
|
1564
|
+
const _cellDbg2 = (globalThis as any).__silvery_cell_debug as { x: number; y: number; log: string[] } | undefined
|
|
1565
|
+
if (_cellDbg2) {
|
|
1566
|
+
const covers =
|
|
1567
|
+
clearX <= _cellDbg2.x &&
|
|
1568
|
+
clearX + clearWidth > _cellDbg2.x &&
|
|
1569
|
+
clearY <= _cellDbg2.y &&
|
|
1570
|
+
clearY + clearHeight > _cellDbg2.y
|
|
1571
|
+
if (covers) {
|
|
1572
|
+
const id = ((node.props as Record<string, unknown>).id as string) ?? node.type
|
|
1573
|
+
_cellDbg2.log.push(
|
|
1574
|
+
`CLEAR_REGION ${id} fill=${clearX},${clearY} ${clearWidth}x${clearHeight} bg=${String(clearBg)} COVERS TARGET`,
|
|
1575
|
+
)
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
buffer.fill(clearX, clearY, clearWidth, clearHeight, {
|
|
1579
|
+
char: " ",
|
|
1580
|
+
bg: clearBg,
|
|
1581
|
+
})
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
// Delegate excess area clearing to shared helper
|
|
1585
|
+
clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, inherited)
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
/**
|
|
1589
|
+
* Clear the excess area when a node shrinks (old bounds were larger than new).
|
|
1590
|
+
*
|
|
1591
|
+
* This is separated from clearNodeRegion because excess area clearing must happen
|
|
1592
|
+
* even when parentRegionCleared is false. Key scenario: absolute-positioned overlays
|
|
1593
|
+
* (e.g., search dialog) that shrink while normal-flow siblings are dirty. The
|
|
1594
|
+
* forceRepaint path sets hasPrevBuffer=false + ancestorCleared=false, making
|
|
1595
|
+
* parentRegionCleared=false — but the cloned buffer still has stale pixels from
|
|
1596
|
+
* the old larger layout that must be cleared.
|
|
1597
|
+
*
|
|
1598
|
+
* Clips to the COLORED ANCESTOR's content area (not immediate parent's full rect)
|
|
1599
|
+
* to prevent inherited color from bleeding into sibling areas with different bg.
|
|
1600
|
+
*
|
|
1601
|
+
* IMPORTANT: Uses content area (inside border/padding), not full contentRect.
|
|
1602
|
+
* Without this, excess clearing of a child that previously filled the parent's
|
|
1603
|
+
* content area will extend into the parent's border row, overwriting border chars.
|
|
1604
|
+
*/
|
|
1605
|
+
function clearExcessArea(
|
|
1606
|
+
node: TeaNode,
|
|
1607
|
+
buffer: TerminalBuffer,
|
|
1608
|
+
layout: NonNullable<TeaNode["contentRect"]>,
|
|
1609
|
+
scrollOffset: number,
|
|
1610
|
+
clipBounds: ClipBounds | undefined,
|
|
1611
|
+
layoutChanged: boolean,
|
|
1612
|
+
inherited?: InheritedBgResult,
|
|
1613
|
+
): void {
|
|
1614
|
+
if (!layoutChanged || !node.prevLayout) return
|
|
1615
|
+
const prev = node.prevLayout
|
|
1616
|
+
|
|
1617
|
+
// Cell debug: log clearExcessArea decisions
|
|
1618
|
+
const _cellDbg3 = (globalThis as any).__silvery_cell_debug as { x: number; y: number; log: string[] } | undefined
|
|
1619
|
+
const _prevCoversCell3 =
|
|
1620
|
+
_cellDbg3 &&
|
|
1621
|
+
prev.x <= _cellDbg3.x &&
|
|
1622
|
+
prev.x + prev.width > _cellDbg3.x &&
|
|
1623
|
+
prev.y - scrollOffset <= _cellDbg3.y &&
|
|
1624
|
+
prev.y - scrollOffset + prev.height > _cellDbg3.y
|
|
1625
|
+
|
|
1626
|
+
// Only clear if the node actually shrank in at least one dimension
|
|
1627
|
+
if (prev.width <= layout.width && prev.height <= layout.height) {
|
|
1628
|
+
if (_cellDbg3 && _prevCoversCell3) {
|
|
1629
|
+
const id = ((node.props as Record<string, unknown>).id as string) ?? node.type
|
|
1630
|
+
_cellDbg3.log.push(
|
|
1631
|
+
`EXCESS_SKIP_NO_SHRINK ${id} prev=${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` +
|
|
1632
|
+
` now=${layout.x},${layout.y - scrollOffset} ${layout.width}x${layout.height}`,
|
|
1633
|
+
)
|
|
1634
|
+
}
|
|
1635
|
+
return
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
// Skip excess clearing when the node MOVED (changed x or y position).
|
|
1639
|
+
// The right/bottom excess formulas use new-x + old-y coordinates, which
|
|
1640
|
+
// creates a phantom rectangle at wrong positions when the node moved.
|
|
1641
|
+
// Example: text at old=(30,7,23,1) → new=(22,8,14,2) computes excess at
|
|
1642
|
+
// (36,7) which overwrites a sibling's border character.
|
|
1643
|
+
//
|
|
1644
|
+
// When the node moved, the parent handles old-pixel cleanup:
|
|
1645
|
+
// - Parent's clearNodeRegion covers old pixels within parent's current rect
|
|
1646
|
+
// - Parent's clearExcessArea covers old pixels outside parent's rect
|
|
1647
|
+
if (prev.x !== layout.x || prev.y !== layout.y) {
|
|
1648
|
+
if (_cellDbg3 && _prevCoversCell3) {
|
|
1649
|
+
const id = ((node.props as Record<string, unknown>).id as string) ?? node.type
|
|
1650
|
+
_cellDbg3.log.push(
|
|
1651
|
+
`EXCESS_SKIP_MOVED ${id} prev=${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` +
|
|
1652
|
+
` now=${layout.x},${layout.y - scrollOffset} ${layout.width}x${layout.height}` +
|
|
1653
|
+
` (dx=${layout.x - prev.x} dy=${layout.y - prev.y})`,
|
|
1654
|
+
)
|
|
1655
|
+
}
|
|
1656
|
+
return
|
|
1657
|
+
}
|
|
1658
|
+
|
|
1659
|
+
if (!inherited) inherited = findInheritedBg(node)
|
|
1660
|
+
const clearBg = inherited.color
|
|
1661
|
+
const screenY = layout.y - scrollOffset
|
|
1662
|
+
const prevScreenY = prev.y - scrollOffset
|
|
1663
|
+
|
|
1664
|
+
// Clip to prevent excess clearing from bleeding outside valid bounds.
|
|
1665
|
+
// Start with the colored ancestor's rect (prevents bg color bleed),
|
|
1666
|
+
// then further restrict to the immediate parent's content area (prevents
|
|
1667
|
+
// overwriting parent's border characters).
|
|
1668
|
+
const clipRect = inherited.ancestorRect ?? node.parent?.contentRect
|
|
1669
|
+
if (!clipRect) return
|
|
1670
|
+
|
|
1671
|
+
const clipRectScreenY = clipRect.y - scrollOffset
|
|
1672
|
+
let clipRectBottom = clipRectScreenY + clipRect.height
|
|
1673
|
+
let clipRectRight = clipRect.x + clipRect.width
|
|
1674
|
+
|
|
1675
|
+
// Always inset by the immediate parent's border/padding.
|
|
1676
|
+
// Without this, a child's excess clearing extends into the parent's
|
|
1677
|
+
// border row, overwriting border characters with spaces.
|
|
1678
|
+
// (The old code skipped inset when clip rect came from a colored ancestor,
|
|
1679
|
+
// assuming "its bg fill covers its border area" — but bg fill only covers
|
|
1680
|
+
// the inside, while renderBorder draws characters on the border row.)
|
|
1681
|
+
const parent = node.parent
|
|
1682
|
+
if (parent?.contentRect) {
|
|
1683
|
+
const parentProps = parent.props as BoxProps
|
|
1684
|
+
const border = getBorderSize(parentProps)
|
|
1685
|
+
const padding = getPadding(parentProps)
|
|
1686
|
+
const parentRight = parent.contentRect.x + parent.contentRect.width - border.right - padding.right
|
|
1687
|
+
const parentBottom =
|
|
1688
|
+
parent.contentRect.y - scrollOffset + parent.contentRect.height - border.bottom - padding.bottom
|
|
1689
|
+
clipRectRight = Math.min(clipRectRight, parentRight)
|
|
1690
|
+
clipRectBottom = Math.min(clipRectBottom, parentBottom)
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
// Clear right margin (old was wider than new)
|
|
1694
|
+
if (prev.width > layout.width) {
|
|
1695
|
+
let excessX = layout.x + layout.width
|
|
1696
|
+
let excessWidth = prev.width - layout.width
|
|
1697
|
+
// Clip horizontally to parent's content area (inside border/padding).
|
|
1698
|
+
// Without this, excess clearing of a child that previously filled a wider
|
|
1699
|
+
// layout extends into the parent's right border, overwriting border chars.
|
|
1700
|
+
if (excessX + excessWidth > clipRectRight) {
|
|
1701
|
+
excessWidth = Math.max(0, clipRectRight - excessX)
|
|
1702
|
+
}
|
|
1703
|
+
if (excessWidth > 0) {
|
|
1704
|
+
clippedFill(
|
|
1705
|
+
buffer,
|
|
1706
|
+
excessX,
|
|
1707
|
+
excessWidth,
|
|
1708
|
+
prevScreenY,
|
|
1709
|
+
prevScreenY + prev.height,
|
|
1710
|
+
clipBounds,
|
|
1711
|
+
clipRectBottom,
|
|
1712
|
+
clearBg,
|
|
1713
|
+
)
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
// Clear bottom margin (old was taller than new)
|
|
1718
|
+
if (prev.height > layout.height) {
|
|
1719
|
+
let bottomWidth = prev.width
|
|
1720
|
+
// Clip horizontally to parent's content area
|
|
1721
|
+
if (layout.x + bottomWidth > clipRectRight) {
|
|
1722
|
+
bottomWidth = Math.max(0, clipRectRight - layout.x)
|
|
1723
|
+
}
|
|
1724
|
+
clippedFill(
|
|
1725
|
+
buffer,
|
|
1726
|
+
layout.x,
|
|
1727
|
+
bottomWidth,
|
|
1728
|
+
screenY + layout.height,
|
|
1729
|
+
prevScreenY + prev.height,
|
|
1730
|
+
clipBounds,
|
|
1731
|
+
clipRectBottom,
|
|
1732
|
+
clearBg,
|
|
1733
|
+
)
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
/** Fill a rectangular region, clipping to clipBounds and an outer bottom limit. */
|
|
1738
|
+
function clippedFill(
|
|
1739
|
+
buffer: TerminalBuffer,
|
|
1740
|
+
x: number,
|
|
1741
|
+
width: number,
|
|
1742
|
+
top: number,
|
|
1743
|
+
bottom: number,
|
|
1744
|
+
clipBounds: ClipBounds | undefined,
|
|
1745
|
+
outerBottom: number,
|
|
1746
|
+
bg: Color,
|
|
1747
|
+
): void {
|
|
1748
|
+
const clippedTop = clipBounds ? Math.max(top, clipBounds.top) : top
|
|
1749
|
+
const clippedBottom = Math.min(clipBounds ? Math.min(bottom, clipBounds.bottom) : bottom, outerBottom)
|
|
1750
|
+
let clippedX = x
|
|
1751
|
+
let clippedWidth = width
|
|
1752
|
+
if (clipBounds?.left !== undefined && clipBounds.right !== undefined) {
|
|
1753
|
+
if (clippedX < clipBounds.left) {
|
|
1754
|
+
clippedWidth -= clipBounds.left - clippedX
|
|
1755
|
+
clippedX = clipBounds.left
|
|
1756
|
+
}
|
|
1757
|
+
if (clippedX + clippedWidth > clipBounds.right) {
|
|
1758
|
+
clippedWidth = Math.max(0, clipBounds.right - clippedX)
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
const height = clippedBottom - clippedTop
|
|
1762
|
+
if (height > 0 && clippedWidth > 0) {
|
|
1763
|
+
buffer.fill(clippedX, clippedTop, clippedWidth, height, { char: " ", bg })
|
|
1764
|
+
}
|
|
1765
|
+
}
|