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