silvery 0.17.0 → 0.17.2
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/dist/UPNG-AVSMjiFE.mjs +5076 -0
- package/dist/UPNG-AVSMjiFE.mjs.map +1 -0
- package/dist/__vite-browser-external-2447137e-D3GdsvS_.mjs +6 -0
- package/dist/__vite-browser-external-2447137e-D3GdsvS_.mjs.map +1 -0
- package/dist/animation-C_PTO0uH.mjs +304 -0
- package/dist/animation-C_PTO0uH.mjs.map +1 -0
- package/dist/ansi-CXLE_pt1.mjs +71 -0
- package/dist/ansi-CXLE_pt1.mjs.map +1 -0
- package/dist/ansi-zmNzgkPB.d.mts +49 -0
- package/dist/ansi-zmNzgkPB.d.mts.map +1 -0
- package/dist/apng-DCWY913R.mjs +3 -0
- package/dist/apng-ENBAJk-H.mjs +70 -0
- package/dist/apng-ENBAJk-H.mjs.map +1 -0
- package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
- package/dist/backend-CkIkIHR-.mjs +13396 -0
- package/dist/backend-CkIkIHR-.mjs.map +1 -0
- package/dist/backends-CkvbG3js.mjs +1181 -0
- package/dist/backends-CkvbG3js.mjs.map +1 -0
- package/dist/backends-CyJqNLeK.mjs +3 -0
- package/dist/chunk-BSw8zbkd.mjs +37 -0
- package/dist/cli-B-k7Bm56.mjs +4 -0
- package/dist/context-QreF3UHr.mjs +64 -0
- package/dist/context-QreF3UHr.mjs.map +1 -0
- package/dist/derive-D7bFJdfU.d.mts +28 -0
- package/dist/derive-D7bFJdfU.d.mts.map +1 -0
- package/dist/devtools-CscuKaDK.mjs +89 -0
- package/dist/devtools-CscuKaDK.mjs.map +1 -0
- package/dist/devtools-D4oGc6LY.mjs +2 -0
- package/dist/eta-DLiVPaSD.mjs +110 -0
- package/dist/eta-DLiVPaSD.mjs.map +1 -0
- package/dist/flexily-zero-adapter-DmG4Ge8t.mjs +3376 -0
- package/dist/flexily-zero-adapter-DmG4Ge8t.mjs.map +1 -0
- package/dist/flexily-zero-adapter-GHwEW11s.mjs +2 -0
- package/dist/gif-BaJNREpP.mjs +3 -0
- package/dist/gif-Bp6fIyN3.mjs +73 -0
- package/dist/gif-Bp6fIyN3.mjs.map +1 -0
- package/dist/gifenc-GiVCZ9-3.mjs +730 -0
- package/dist/gifenc-GiVCZ9-3.mjs.map +1 -0
- package/dist/image-Dx7gYjkq.mjs +346 -0
- package/dist/image-Dx7gYjkq.mjs.map +1 -0
- package/dist/index-CBcSpGSM.d.mts +3416 -0
- package/dist/index-CBcSpGSM.d.mts.map +1 -0
- package/dist/index-DCVL3jHo.d.mts +634 -0
- package/dist/index-DCVL3jHo.d.mts.map +1 -0
- package/dist/index-p-wBs_wH.d.mts +175 -0
- package/dist/index-p-wBs_wH.d.mts.map +1 -0
- package/dist/index.d.mts +7296 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +9399 -0
- package/dist/index.mjs.map +1 -0
- package/dist/key-mapping-BsUHe_nk.mjs +3 -0
- package/dist/key-mapping-DsyfLEdC.mjs +132 -0
- package/dist/key-mapping-DsyfLEdC.mjs.map +1 -0
- package/dist/layout-engine-B3dsnVLU.mjs +50 -0
- package/dist/layout-engine-B3dsnVLU.mjs.map +1 -0
- package/dist/layout-engine-D_lSR4i9.mjs +2 -0
- package/dist/multi-progress-C0-rkn86.d.mts +180 -0
- package/dist/multi-progress-C0-rkn86.d.mts.map +1 -0
- package/dist/multi-progress-CQVB9lES.mjs +219 -0
- package/dist/multi-progress-CQVB9lES.mjs.map +1 -0
- package/dist/node-Dedx-6xF.mjs +1085 -0
- package/dist/node-Dedx-6xF.mjs.map +1 -0
- package/dist/pipeline-DDOPrjuY.mjs +4387 -0
- package/dist/pipeline-DDOPrjuY.mjs.map +1 -0
- package/dist/progress-bar-COPSBlT9.mjs +155 -0
- package/dist/progress-bar-COPSBlT9.mjs.map +1 -0
- package/dist/reconciler-2lp5VXK7.mjs +16506 -0
- package/dist/reconciler-2lp5VXK7.mjs.map +1 -0
- package/dist/render-string-BXvxTg5P.mjs +201 -0
- package/dist/render-string-BXvxTg5P.mjs.map +1 -0
- package/dist/render-string-hvfpVtoP.mjs +2 -0
- package/dist/resvg-js-V6oMi8CY.mjs +203 -0
- package/dist/resvg-js-V6oMi8CY.mjs.map +1 -0
- package/dist/runtime-BjDHNTxJ.mjs +8723 -0
- package/dist/runtime-BjDHNTxJ.mjs.map +1 -0
- package/dist/runtime.d.mts +2 -0
- package/dist/runtime.mjs +3 -0
- package/dist/spinner-Cgej6Vnb.d.mts +127 -0
- package/dist/spinner-Cgej6Vnb.d.mts.map +1 -0
- package/dist/spinner-DSByknyx.mjs +298 -0
- package/dist/spinner-DSByknyx.mjs.map +1 -0
- package/dist/src-9B5k0JmY.mjs +1629 -0
- package/dist/src-9B5k0JmY.mjs.map +1 -0
- package/dist/src-C9f3hiVG.mjs +3620 -0
- package/dist/src-C9f3hiVG.mjs.map +1 -0
- package/dist/src-fJVbhdn-.mjs +816 -0
- package/dist/src-fJVbhdn-.mjs.map +1 -0
- package/dist/theme.d.mts +115 -0
- package/dist/theme.d.mts.map +1 -0
- package/dist/theme.mjs +8 -0
- package/dist/theme.mjs.map +1 -0
- package/dist/types-Bhj5QkIQ.mjs +13 -0
- package/dist/types-Bhj5QkIQ.mjs.map +1 -0
- package/dist/types-CDgkE-Rw.d.mts +241 -0
- package/dist/types-CDgkE-Rw.d.mts.map +1 -0
- package/dist/ui/animation.d.mts +2 -0
- package/dist/ui/animation.mjs +2 -0
- package/dist/ui/ansi.d.mts +2 -0
- package/dist/ui/ansi.mjs +2 -0
- package/dist/ui/cli.d.mts +5 -0
- package/dist/ui/cli.mjs +7 -0
- package/dist/ui/display.d.mts +35 -0
- package/dist/ui/display.d.mts.map +1 -0
- package/dist/ui/display.mjs +123 -0
- package/dist/ui/display.mjs.map +1 -0
- package/dist/ui/image.d.mts +2 -0
- package/dist/ui/image.mjs +2 -0
- package/dist/ui/input.d.mts +184 -0
- package/dist/ui/input.d.mts.map +1 -0
- package/dist/ui/input.mjs +285 -0
- package/dist/ui/input.mjs.map +1 -0
- package/dist/ui/progress.d.mts +249 -0
- package/dist/ui/progress.d.mts.map +1 -0
- package/dist/ui/progress.mjs +858 -0
- package/dist/ui/progress.mjs.map +1 -0
- package/dist/ui/react.d.mts +280 -0
- package/dist/ui/react.d.mts.map +1 -0
- package/dist/ui/react.mjs +413 -0
- package/dist/ui/react.mjs.map +1 -0
- package/dist/ui/utils.d.mts +86 -0
- package/dist/ui/utils.d.mts.map +1 -0
- package/dist/ui/utils.mjs +2 -0
- package/dist/ui/wrappers.d.mts +3 -0
- package/dist/ui/wrappers.mjs +2 -0
- package/dist/ui.d.mts +6 -0
- package/dist/ui.mjs +7 -0
- package/dist/useLatest-BMIYXd6e.d.mts +154 -0
- package/dist/useLatest-BMIYXd6e.d.mts.map +1 -0
- package/dist/useLayout-BG2cGl15.mjs +139 -0
- package/dist/useLayout-BG2cGl15.mjs.map +1 -0
- package/dist/with-text-input-CmHf_9d6.d.mts +284 -0
- package/dist/with-text-input-CmHf_9d6.d.mts.map +1 -0
- package/dist/wrapper-Dqh0zi2W.mjs +3527 -0
- package/dist/wrapper-Dqh0zi2W.mjs.map +1 -0
- package/dist/wrappers-hhL8EQ_n.mjs +810 -0
- package/dist/wrappers-hhL8EQ_n.mjs.map +1 -0
- package/dist/yoga-adapter-BJ9SOhTY.mjs +245 -0
- package/dist/yoga-adapter-BJ9SOhTY.mjs.map +1 -0
- package/dist/yoga-adapter-Daq6-dw1.mjs +2 -0
- package/package.json +48 -75
- package/CHANGELOG.md +0 -319
- package/dist/chalk.js +0 -4
- package/dist/index.js +0 -270
- package/dist/ink.js +0 -142
- package/dist/runtime.js +0 -135
- package/dist/theme.js +0 -7
- package/dist/ui/animation.js +0 -3
- package/dist/ui/ansi.js +0 -3
- package/dist/ui/cli.js +0 -9
- package/dist/ui/display.js +0 -4
- package/dist/ui/image.js +0 -4
- package/dist/ui/input.js +0 -3
- package/dist/ui/progress.js +0 -9
- package/dist/ui/react.js +0 -4
- package/dist/ui/utils.js +0 -3
- package/dist/ui/wrappers.js +0 -15
- package/dist/ui.js +0 -18
- package/src/index.ts +0 -73
- package/src/runtime.ts +0 -4
- package/src/theme.ts +0 -4
- package/src/ui/animation.ts +0 -2
- package/src/ui/ansi.ts +0 -2
- package/src/ui/cli.ts +0 -3
- package/src/ui/display.ts +0 -2
- package/src/ui/image.ts +0 -2
- package/src/ui/input.ts +0 -2
- package/src/ui/progress.ts +0 -2
- package/src/ui/react.ts +0 -2
- package/src/ui/utils.ts +0 -2
- package/src/ui/wrappers.ts +0 -2
- package/src/ui.ts +0 -4
|
@@ -0,0 +1,4387 @@
|
|
|
1
|
+
import { F as outputPhase, Ft as createMutableCell, G as getActiveLineHeight, It as createTextFrame, J as graphemeWidth, Lt as init_buffer, Ot as DEFAULT_BG, R as canBreakAnywhere, U as displayWidthAnsi, W as ensureEmojiPresentation, Y as hasAnsi, _ as isCurrentEpoch, c as clearDirtyTracking, ct as runWithMeasurer, d as hasScrollDirty, dt as sliceByWidth, f as measureStats, ft as sliceByWidthFromEnd, g as isAnyDirty, h as getRenderEpoch, ht as splitGraphemesAnsiAware, kt as TerminalBuffer, l as clearLayoutDirtyTracking, m as advanceRenderEpoch, mt as splitGraphemes, nt as isWordBoundary, p as collectPlainText, st as parseAnsiText, u as hasLayoutDirty, v as isDirty, yt as wrapText } from "./reconciler-2lp5VXK7.mjs";
|
|
2
|
+
import { C as resolveThemeColor } from "./src-9B5k0JmY.mjs";
|
|
3
|
+
import { D as init_state, E as getActiveTheme, O as popContextTheme, k as pushContextTheme, x as init_resolve } from "./src-C9f3hiVG.mjs";
|
|
4
|
+
import { r as getLayoutEngine } from "./layout-engine-B3dsnVLU.mjs";
|
|
5
|
+
import { t as rectEqual } from "./types-Bhj5QkIQ.mjs";
|
|
6
|
+
import { createLogger } from "loggily";
|
|
7
|
+
//#region packages/ag-term/src/pipeline/prepared-text.ts
|
|
8
|
+
const MAX_FORMAT_ENTRIES = 4;
|
|
9
|
+
/** Content-affecting flags that invalidate plain text. */
|
|
10
|
+
const PLAIN_TEXT_DIRTY = 9;
|
|
11
|
+
/**
|
|
12
|
+
* All flags that affect collected text (ANSI codes, bg segments, child spans).
|
|
13
|
+
* SUBTREE_BIT is included because collectTextWithBg recurses into virtual text
|
|
14
|
+
* children — a child's style change sets SUBTREE_BIT on the parent without
|
|
15
|
+
* setting STYLE_PROPS_BIT (the parent's own props didn't change).
|
|
16
|
+
*/
|
|
17
|
+
const COLLECTED_TEXT_DIRTY = 31;
|
|
18
|
+
/** Set to true to disable all caching (for testing/debugging). */
|
|
19
|
+
let _cacheDisabled = !!process.env.SILVERY_NO_TEXT_CACHE;
|
|
20
|
+
const textCaches = /* @__PURE__ */ new WeakMap();
|
|
21
|
+
function getOrCreate(node) {
|
|
22
|
+
let entry = textCaches.get(node);
|
|
23
|
+
if (!entry) {
|
|
24
|
+
entry = {
|
|
25
|
+
plainText: null,
|
|
26
|
+
plainTextLineCount: 0,
|
|
27
|
+
collected: null,
|
|
28
|
+
collectedMaxDisplayWidth: void 0,
|
|
29
|
+
formats: [],
|
|
30
|
+
analysis: null
|
|
31
|
+
};
|
|
32
|
+
textCaches.set(node, entry);
|
|
33
|
+
}
|
|
34
|
+
return entry;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Get cached plain text and line count.
|
|
38
|
+
* Returns null on cache miss (content/children changed or first access).
|
|
39
|
+
*/
|
|
40
|
+
function getCachedPlainText(node) {
|
|
41
|
+
if (_cacheDisabled) return null;
|
|
42
|
+
const entry = textCaches.get(node);
|
|
43
|
+
if (entry?.plainText == null) return null;
|
|
44
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, PLAIN_TEXT_DIRTY)) {
|
|
45
|
+
entry.plainText = null;
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
text: entry.plainText,
|
|
50
|
+
lineCount: entry.plainTextLineCount
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/** Store plain text in cache. */
|
|
54
|
+
function setCachedPlainText(node, text, lineCount) {
|
|
55
|
+
const entry = getOrCreate(node);
|
|
56
|
+
entry.plainText = text;
|
|
57
|
+
entry.plainTextLineCount = lineCount;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Get cached collected text (from collectTextWithBg).
|
|
61
|
+
* Invalidated by content, children, style, or bg changes, or maxDisplayWidth mismatch.
|
|
62
|
+
*/
|
|
63
|
+
function getCachedCollectedText(node, maxDisplayWidth) {
|
|
64
|
+
if (_cacheDisabled) return null;
|
|
65
|
+
const entry = textCaches.get(node);
|
|
66
|
+
if (!entry?.collected) return null;
|
|
67
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, COLLECTED_TEXT_DIRTY)) {
|
|
68
|
+
entry.collected = null;
|
|
69
|
+
entry.formats = [];
|
|
70
|
+
entry.analysis = null;
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
if (entry.collectedMaxDisplayWidth !== maxDisplayWidth) {
|
|
74
|
+
entry.collected = null;
|
|
75
|
+
entry.formats = [];
|
|
76
|
+
entry.analysis = null;
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return entry.collected;
|
|
80
|
+
}
|
|
81
|
+
/** Store collected text in cache. */
|
|
82
|
+
function setCachedCollectedText(node, result, maxDisplayWidth) {
|
|
83
|
+
const entry = getOrCreate(node);
|
|
84
|
+
entry.collected = result;
|
|
85
|
+
entry.collectedMaxDisplayWidth = maxDisplayWidth;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get cached formatted lines for the given width/wrap/trim.
|
|
89
|
+
* Returns null on cache miss.
|
|
90
|
+
*/
|
|
91
|
+
function getCachedFormat(node, width, wrap, trim) {
|
|
92
|
+
if (_cacheDisabled) return null;
|
|
93
|
+
const entry = textCaches.get(node);
|
|
94
|
+
if (!entry || entry.formats.length === 0) return null;
|
|
95
|
+
for (let i = 0; i < entry.formats.length; i++) {
|
|
96
|
+
const f = entry.formats[i];
|
|
97
|
+
if (f.width === width && f.wrap === wrap && f.trim === trim) {
|
|
98
|
+
if (i < entry.formats.length - 1) {
|
|
99
|
+
entry.formats.splice(i, 1);
|
|
100
|
+
entry.formats.push(f);
|
|
101
|
+
}
|
|
102
|
+
return f;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
/** Store formatted lines in cache (LRU, evicts oldest when full). */
|
|
108
|
+
function setCachedFormat(node, width, wrap, trim, lines, lineOffsets, hasLineOffsets) {
|
|
109
|
+
const entry = getOrCreate(node);
|
|
110
|
+
for (let i = 0; i < entry.formats.length; i++) {
|
|
111
|
+
const f = entry.formats[i];
|
|
112
|
+
if (f.width === width && f.wrap === wrap && f.trim === trim) {
|
|
113
|
+
entry.formats[i] = {
|
|
114
|
+
width,
|
|
115
|
+
wrap,
|
|
116
|
+
trim,
|
|
117
|
+
lines,
|
|
118
|
+
lineOffsets,
|
|
119
|
+
hasLineOffsets
|
|
120
|
+
};
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (entry.formats.length >= MAX_FORMAT_ENTRIES) entry.formats.shift();
|
|
125
|
+
entry.formats.push({
|
|
126
|
+
width,
|
|
127
|
+
wrap,
|
|
128
|
+
trim,
|
|
129
|
+
lines,
|
|
130
|
+
lineOffsets,
|
|
131
|
+
hasLineOffsets
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Get cached text analysis. Invalidated when content changes.
|
|
136
|
+
* Uses PLAIN_TEXT_DIRTY (not COLLECTED_TEXT_DIRTY) because analysis
|
|
137
|
+
* is built from plain text in measure phase, not styled text.
|
|
138
|
+
*/
|
|
139
|
+
function getCachedAnalysis(node) {
|
|
140
|
+
if (_cacheDisabled) return null;
|
|
141
|
+
const entry = textCaches.get(node);
|
|
142
|
+
if (!entry?.analysis) return null;
|
|
143
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, PLAIN_TEXT_DIRTY)) {
|
|
144
|
+
entry.analysis = null;
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return entry.analysis;
|
|
148
|
+
}
|
|
149
|
+
/** Store text analysis in cache. */
|
|
150
|
+
function setCachedAnalysis(node, analysis) {
|
|
151
|
+
const entry = getOrCreate(node);
|
|
152
|
+
entry.analysis = analysis;
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
//#region packages/ag-term/src/pipeline/pretext.ts
|
|
156
|
+
/**
|
|
157
|
+
* Pretext: Grapheme-indexed text analysis for layout queries.
|
|
158
|
+
*
|
|
159
|
+
* Inspired by https://chenglou.me/pretext/ — prepare text once, measure at
|
|
160
|
+
* any width cheaply. Enables layout algorithms CSS can't express:
|
|
161
|
+
*
|
|
162
|
+
* - **Shrinkwrap**: find the narrowest width that keeps the same line count
|
|
163
|
+
* - **Balanced**: equalize line widths (reduce raggedness)
|
|
164
|
+
* - **Knuth-Plass**: optimal paragraph breaking (minimize total raggedness)
|
|
165
|
+
* - **Height prediction**: exact line count at any width without full wrapping
|
|
166
|
+
*/
|
|
167
|
+
/**
|
|
168
|
+
* Build text analysis from an ANSI-embedded text string.
|
|
169
|
+
* O(N) where N is grapheme count. Call once per text change (cached by PreparedText).
|
|
170
|
+
*/
|
|
171
|
+
function buildTextAnalysis(text, gWidthFn = graphemeWidth) {
|
|
172
|
+
const graphemes = splitGraphemesAnsiAware(text);
|
|
173
|
+
const len = graphemes.length;
|
|
174
|
+
const widths = new Array(len);
|
|
175
|
+
const cumWidths = new Array(len + 1);
|
|
176
|
+
const newlineIndices = [];
|
|
177
|
+
const breakIndices = [];
|
|
178
|
+
cumWidths[0] = 0;
|
|
179
|
+
let maxWordWidth = 0;
|
|
180
|
+
let maxGraphemeWidth = 0;
|
|
181
|
+
let currentWordWidth = 0;
|
|
182
|
+
for (let i = 0; i < len; i++) {
|
|
183
|
+
const g = graphemes[i];
|
|
184
|
+
const w = gWidthFn(g);
|
|
185
|
+
widths[i] = w;
|
|
186
|
+
cumWidths[i + 1] = cumWidths[i] + w;
|
|
187
|
+
if (w > maxGraphemeWidth) maxGraphemeWidth = w;
|
|
188
|
+
if (g === "\n") {
|
|
189
|
+
newlineIndices.push(i);
|
|
190
|
+
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
191
|
+
currentWordWidth = 0;
|
|
192
|
+
} else if (isWordBoundary(g)) {
|
|
193
|
+
breakIndices.push(i + 1);
|
|
194
|
+
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
195
|
+
currentWordWidth = 0;
|
|
196
|
+
} else if (canBreakAnywhere(g)) {
|
|
197
|
+
breakIndices.push(i);
|
|
198
|
+
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
199
|
+
currentWordWidth = w;
|
|
200
|
+
} else if (w > 0) currentWordWidth += w;
|
|
201
|
+
}
|
|
202
|
+
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
203
|
+
return {
|
|
204
|
+
graphemes,
|
|
205
|
+
widths,
|
|
206
|
+
cumWidths,
|
|
207
|
+
totalWidth: cumWidths[len],
|
|
208
|
+
maxWordWidth,
|
|
209
|
+
maxGraphemeWidth,
|
|
210
|
+
newlineIndices,
|
|
211
|
+
breakIndices,
|
|
212
|
+
text
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Count how many lines text would occupy at a given width.
|
|
217
|
+
*
|
|
218
|
+
* Delegates to wrapText for correctness — the greedy wrapping algorithm has
|
|
219
|
+
* subtle boundary-char handling (spaces consumed on overflow, leading space
|
|
220
|
+
* trimming on continuation lines) that's error-prone to reimplement.
|
|
221
|
+
*
|
|
222
|
+
* For terminal text (20-200 chars), wrapText is ~5-12µs per call.
|
|
223
|
+
* Shrinkwrap does ~7-9 calls (log2(width)), so total is ~50-100µs.
|
|
224
|
+
*/
|
|
225
|
+
function countLinesAtWidth(analysis, width) {
|
|
226
|
+
if (width <= 0) return Infinity;
|
|
227
|
+
if (analysis.totalWidth <= width && analysis.newlineIndices.length === 0) return 1;
|
|
228
|
+
return wrapText(analysis.text, width, true, true).length;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Find the narrowest integer width that produces the same line count as maxWidth.
|
|
232
|
+
*
|
|
233
|
+
* CSS fit-content uses the widest wrapped line — leaving dead space when the
|
|
234
|
+
* last line is short. Shrinkwrap binary-searches for the tightest width that
|
|
235
|
+
* keeps the same number of lines, eliminating wasted area in bubbles/cards.
|
|
236
|
+
*
|
|
237
|
+
* O(log(maxWidth) × wrapText) — ~7-9 iterations for terminal widths.
|
|
238
|
+
*/
|
|
239
|
+
function shrinkwrapWidth(analysis, maxWidth) {
|
|
240
|
+
if (maxWidth <= 0) return 0;
|
|
241
|
+
const targetLineCount = countLinesAtWidth(analysis, maxWidth);
|
|
242
|
+
if (targetLineCount <= 1) return Math.min(Math.ceil(analysis.totalWidth), maxWidth);
|
|
243
|
+
let lo = Math.max(1, analysis.maxGraphemeWidth);
|
|
244
|
+
let hi = maxWidth;
|
|
245
|
+
if (lo >= hi) return Math.min(hi, maxWidth);
|
|
246
|
+
while (lo < hi) {
|
|
247
|
+
const mid = lo + hi >> 1;
|
|
248
|
+
if (countLinesAtWidth(analysis, mid) <= targetLineCount) hi = mid;
|
|
249
|
+
else lo = mid + 1;
|
|
250
|
+
}
|
|
251
|
+
return Math.min(lo, maxWidth);
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Find optimal line breaks that minimize total raggedness.
|
|
255
|
+
*
|
|
256
|
+
* Runs per-paragraph (split by newlines) to avoid penalty interactions
|
|
257
|
+
* around forced breaks. Falls back to greedy wrapping for paragraphs
|
|
258
|
+
* where the DP finds no feasible solution (overlong words).
|
|
259
|
+
*
|
|
260
|
+
* O(breakpoints²) per paragraph, typically much less with pruning.
|
|
261
|
+
*/
|
|
262
|
+
function knuthPlassBreaks(analysis, width) {
|
|
263
|
+
if (width <= 0) return [];
|
|
264
|
+
if (analysis.totalWidth <= width && analysis.newlineIndices.length === 0) return [];
|
|
265
|
+
const { newlineIndices, graphemes } = analysis;
|
|
266
|
+
const allBreaks = [];
|
|
267
|
+
const paragraphStarts = [0];
|
|
268
|
+
for (const nl of newlineIndices) paragraphStarts.push(nl + 1);
|
|
269
|
+
for (let p = 0; p < paragraphStarts.length; p++) {
|
|
270
|
+
const pStart = paragraphStarts[p];
|
|
271
|
+
const pEnd = p + 1 < paragraphStarts.length ? paragraphStarts[p + 1] - 1 : graphemes.length;
|
|
272
|
+
if (pStart >= pEnd) continue;
|
|
273
|
+
const breaks = knuthPlassForParagraph(analysis, pStart, pEnd, width);
|
|
274
|
+
allBreaks.push(...breaks);
|
|
275
|
+
if (p < paragraphStarts.length - 1 && pEnd < graphemes.length) allBreaks.push(pEnd + 1);
|
|
276
|
+
}
|
|
277
|
+
return allBreaks;
|
|
278
|
+
}
|
|
279
|
+
/** DP for a single paragraph (no newlines). */
|
|
280
|
+
function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
|
|
281
|
+
const { cumWidths, breakIndices, widths, graphemes } = analysis;
|
|
282
|
+
const candidates = [pStart];
|
|
283
|
+
for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
|
|
284
|
+
candidates.push(pEnd);
|
|
285
|
+
const n = candidates.length;
|
|
286
|
+
if (n <= 2) return [];
|
|
287
|
+
const cost = new Array(n).fill(Infinity);
|
|
288
|
+
const next = new Array(n).fill(-1);
|
|
289
|
+
cost[n - 1] = 0;
|
|
290
|
+
for (let i = n - 2; i >= 0; i--) {
|
|
291
|
+
const lineStart = candidates[i];
|
|
292
|
+
const lineStartCum = cumWidths[lineStart];
|
|
293
|
+
for (let j = i + 1; j < n; j++) {
|
|
294
|
+
let trimEnd = candidates[j];
|
|
295
|
+
while (trimEnd > lineStart) {
|
|
296
|
+
const prevG = graphemes[trimEnd - 1];
|
|
297
|
+
if (widths[trimEnd - 1] === 0) {
|
|
298
|
+
trimEnd--;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (prevG === " " || prevG === " ") {
|
|
302
|
+
trimEnd--;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
const lineWidth = cumWidths[trimEnd] - lineStartCum;
|
|
308
|
+
if (lineWidth > width) break;
|
|
309
|
+
const leftover = width - lineWidth;
|
|
310
|
+
const totalCost = (j === n - 1 ? 0 : leftover * leftover) + cost[j];
|
|
311
|
+
if (totalCost < cost[i]) {
|
|
312
|
+
cost[i] = totalCost;
|
|
313
|
+
next[i] = j;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (cost[0] === Infinity) return [];
|
|
318
|
+
const breaks = [];
|
|
319
|
+
let idx = 0;
|
|
320
|
+
while (idx < n - 1 && next[idx] >= 0) {
|
|
321
|
+
idx = next[idx];
|
|
322
|
+
if (idx < n - 1) breaks.push(candidates[idx]);
|
|
323
|
+
}
|
|
324
|
+
return breaks;
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Wrap text using Knuth-Plass optimal breaks.
|
|
328
|
+
* Returns line strings — drop-in replacement for greedy wrap.
|
|
329
|
+
* Falls back to greedy wrapText when DP finds no feasible solution.
|
|
330
|
+
*/
|
|
331
|
+
function optimalWrap(text, analysis, width) {
|
|
332
|
+
const breaks = knuthPlassBreaks(analysis, width);
|
|
333
|
+
if (breaks.length === 0) {
|
|
334
|
+
if (analysis.totalWidth <= width && analysis.newlineIndices.length === 0) return [text];
|
|
335
|
+
return wrapText(text, width, true, true);
|
|
336
|
+
}
|
|
337
|
+
const { graphemes, widths } = analysis;
|
|
338
|
+
const lines = [];
|
|
339
|
+
let lineStart = 0;
|
|
340
|
+
for (const bp of breaks) {
|
|
341
|
+
let lineEnd = bp;
|
|
342
|
+
while (lineEnd > lineStart) {
|
|
343
|
+
if (widths[lineEnd - 1] === 0) {
|
|
344
|
+
lineEnd--;
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const g = graphemes[lineEnd - 1];
|
|
348
|
+
if (g === " " || g === " " || g === "\n") {
|
|
349
|
+
lineEnd--;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
lines.push(graphemes.slice(lineStart, lineEnd).join(""));
|
|
355
|
+
lineStart = bp;
|
|
356
|
+
while (lineStart < graphemes.length) {
|
|
357
|
+
const g = graphemes[lineStart];
|
|
358
|
+
if (g === " " || g === " ") {
|
|
359
|
+
lineStart++;
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (lineStart < graphemes.length) lines.push(graphemes.slice(lineStart).join(""));
|
|
366
|
+
return lines;
|
|
367
|
+
}
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region packages/ag-term/src/pipeline/helpers.ts
|
|
370
|
+
/**
|
|
371
|
+
* Get padding values from props.
|
|
372
|
+
*/
|
|
373
|
+
function getPadding(props) {
|
|
374
|
+
return {
|
|
375
|
+
top: props.paddingTop ?? props.paddingY ?? props.padding ?? 0,
|
|
376
|
+
bottom: props.paddingBottom ?? props.paddingY ?? props.padding ?? 0,
|
|
377
|
+
left: props.paddingLeft ?? props.paddingX ?? props.padding ?? 0,
|
|
378
|
+
right: props.paddingRight ?? props.paddingX ?? props.padding ?? 0
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Get border size (1 or 0 for each side).
|
|
383
|
+
* In pixel/canvas mode (lineHeight > 1), borders are visual-only (fillRoundedRect)
|
|
384
|
+
* and don't affect content positioning — returns 0.
|
|
385
|
+
*/
|
|
386
|
+
function getBorderSize(props) {
|
|
387
|
+
if (!props.borderStyle || getActiveLineHeight() > 1) return {
|
|
388
|
+
top: 0,
|
|
389
|
+
bottom: 0,
|
|
390
|
+
left: 0,
|
|
391
|
+
right: 0
|
|
392
|
+
};
|
|
393
|
+
return {
|
|
394
|
+
top: props.borderTop !== false ? 1 : 0,
|
|
395
|
+
bottom: props.borderBottom !== false ? 1 : 0,
|
|
396
|
+
left: props.borderLeft !== false ? 1 : 0,
|
|
397
|
+
right: props.borderRight !== false ? 1 : 0
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
//#endregion
|
|
401
|
+
//#region packages/ag-term/src/pipeline/measure-phase.ts
|
|
402
|
+
/**
|
|
403
|
+
* Handle fit-content nodes by measuring their intrinsic content size.
|
|
404
|
+
*
|
|
405
|
+
* Traverses the tree and for any node with width="fit-content" or
|
|
406
|
+
* height="fit-content", measures the content and sets the Yoga constraint.
|
|
407
|
+
*/
|
|
408
|
+
function measurePhase(root, ctx) {
|
|
409
|
+
traverseTree$1(root, (node) => {
|
|
410
|
+
if (!node.layoutNode) return;
|
|
411
|
+
const props = node.props;
|
|
412
|
+
const isFitContent = props.width === "fit-content" || props.height === "fit-content";
|
|
413
|
+
const isSnugContent = props.width === "snug-content";
|
|
414
|
+
if (isFitContent || isSnugContent) {
|
|
415
|
+
let availableWidth;
|
|
416
|
+
const widthIsFixed = typeof props.width === "number";
|
|
417
|
+
if (props.height === "fit-content" && widthIsFixed) {
|
|
418
|
+
const padding = getPadding(props);
|
|
419
|
+
availableWidth = props.width - padding.left - padding.right;
|
|
420
|
+
if (props.borderStyle) {
|
|
421
|
+
const border = getBorderSize(props);
|
|
422
|
+
availableWidth -= border.left + border.right;
|
|
423
|
+
}
|
|
424
|
+
if (availableWidth < 1) availableWidth = 1;
|
|
425
|
+
}
|
|
426
|
+
const intrinsicSize = measureIntrinsicSize(node, ctx, availableWidth);
|
|
427
|
+
if (isSnugContent) {
|
|
428
|
+
const shrunkWidth = computeSnugContentWidth(node, intrinsicSize.width, ctx);
|
|
429
|
+
node.layoutNode.setWidth(shrunkWidth);
|
|
430
|
+
} else if (props.width === "fit-content") node.layoutNode.setWidth(intrinsicSize.width);
|
|
431
|
+
if (props.height === "fit-content") node.layoutNode.setHeight(intrinsicSize.height);
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Measure the intrinsic size of a node's content.
|
|
437
|
+
*
|
|
438
|
+
* For text nodes: measures the text width and line count.
|
|
439
|
+
* For box nodes: recursively measures children based on flex direction.
|
|
440
|
+
*
|
|
441
|
+
* @param availableWidth - When set, text nodes wrap at this width for height calculation.
|
|
442
|
+
* Used when a container has fixed width + fit-content height.
|
|
443
|
+
*/
|
|
444
|
+
function measureIntrinsicSize(node, ctx, availableWidth) {
|
|
445
|
+
const props = node.props;
|
|
446
|
+
if (props.display === "none") return {
|
|
447
|
+
width: 0,
|
|
448
|
+
height: 0
|
|
449
|
+
};
|
|
450
|
+
if (node.type === "silvery-text") {
|
|
451
|
+
const textProps = props;
|
|
452
|
+
const cached = getCachedPlainText(node);
|
|
453
|
+
let text;
|
|
454
|
+
if (cached) text = cached.text;
|
|
455
|
+
else {
|
|
456
|
+
text = collectPlainText(node);
|
|
457
|
+
const lineCount = (text.match(/\n/g)?.length ?? 0) + 1;
|
|
458
|
+
setCachedPlainText(node, text, lineCount);
|
|
459
|
+
}
|
|
460
|
+
const transform = textProps.internal_transform;
|
|
461
|
+
let lines;
|
|
462
|
+
if (availableWidth !== void 0 && availableWidth > 0 && isWrapEnabled(textProps.wrap)) lines = ctx ? ctx.measurer.wrapText(text, availableWidth, true, true) : wrapText(text, availableWidth, true, true);
|
|
463
|
+
else lines = text.split("\n");
|
|
464
|
+
if (transform) lines = lines.map((line, index) => transform(line, index));
|
|
465
|
+
return {
|
|
466
|
+
width: Math.max(...lines.map((line) => getTextWidth$1(line, ctx))),
|
|
467
|
+
height: lines.length * getActiveLineHeight()
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const isRow = props.flexDirection === "row" || props.flexDirection === "row-reverse";
|
|
471
|
+
let width = 0;
|
|
472
|
+
let height = 0;
|
|
473
|
+
let childCount = 0;
|
|
474
|
+
for (const child of node.children) {
|
|
475
|
+
const childSize = measureIntrinsicSize(child, ctx, availableWidth);
|
|
476
|
+
childCount++;
|
|
477
|
+
if (isRow) {
|
|
478
|
+
width += childSize.width;
|
|
479
|
+
height = Math.max(height, childSize.height);
|
|
480
|
+
} else {
|
|
481
|
+
width = Math.max(width, childSize.width);
|
|
482
|
+
height += childSize.height;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
const gap = props.gap ?? 0;
|
|
486
|
+
if (gap > 0 && childCount > 1) {
|
|
487
|
+
const totalGap = gap * (childCount - 1);
|
|
488
|
+
if (isRow) width += totalGap;
|
|
489
|
+
else height += totalGap;
|
|
490
|
+
}
|
|
491
|
+
const padding = getPadding(props);
|
|
492
|
+
width += padding.left + padding.right;
|
|
493
|
+
height += padding.top + padding.bottom;
|
|
494
|
+
if (props.borderStyle) {
|
|
495
|
+
const border = getBorderSize(props);
|
|
496
|
+
width += border.left + border.right;
|
|
497
|
+
height += border.top + border.bottom;
|
|
498
|
+
}
|
|
499
|
+
return {
|
|
500
|
+
width,
|
|
501
|
+
height
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Check if text wrapping is enabled for a text node.
|
|
506
|
+
*/
|
|
507
|
+
function isWrapEnabled(wrap) {
|
|
508
|
+
return wrap === "wrap" || wrap === "hard" || wrap === "even" || wrap === true || wrap === void 0;
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Compute snug-content width for a node.
|
|
512
|
+
* Uses Pretext analysis to binary-search for the tightest width
|
|
513
|
+
* that keeps the same line count as the fit-content width.
|
|
514
|
+
*/
|
|
515
|
+
function computeSnugContentWidth(node, fitContentWidth, ctx) {
|
|
516
|
+
const props = node.props;
|
|
517
|
+
let overhead = 0;
|
|
518
|
+
const padding = getPadding(props);
|
|
519
|
+
overhead += padding.left + padding.right;
|
|
520
|
+
if (props.borderStyle) {
|
|
521
|
+
const border = getBorderSize(props);
|
|
522
|
+
overhead += border.left + border.right;
|
|
523
|
+
}
|
|
524
|
+
const contentWidth = fitContentWidth - overhead;
|
|
525
|
+
let analysis = getCachedAnalysis(node);
|
|
526
|
+
if (!analysis) {
|
|
527
|
+
const cached = getCachedPlainText(node);
|
|
528
|
+
const text = cached ? cached.text : collectPlainText(node);
|
|
529
|
+
analysis = buildTextAnalysis(text, ctx?.measurer?.graphemeWidth?.bind(ctx.measurer) ?? graphemeWidth);
|
|
530
|
+
setCachedAnalysis(node, analysis);
|
|
531
|
+
if (!cached) setCachedPlainText(node, text, (text.match(/\n/g)?.length ?? 0) + 1);
|
|
532
|
+
}
|
|
533
|
+
return shrinkwrapWidth(analysis, contentWidth) + overhead;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Traverse tree in depth-first order.
|
|
537
|
+
*/
|
|
538
|
+
function traverseTree$1(node, callback) {
|
|
539
|
+
callback(node);
|
|
540
|
+
for (const child of node.children) traverseTree$1(child, callback);
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Get text display width (accounting for wide characters and ANSI codes).
|
|
544
|
+
* Uses ANSI-aware width calculation to handle styled text.
|
|
545
|
+
*/
|
|
546
|
+
function getTextWidth$1(text, ctx) {
|
|
547
|
+
if (ctx) return ctx.measurer.displayWidthAnsi(text);
|
|
548
|
+
return displayWidthAnsi(text);
|
|
549
|
+
}
|
|
550
|
+
//#endregion
|
|
551
|
+
//#region packages/ag-term/src/pipeline/layout-phase.ts
|
|
552
|
+
/**
|
|
553
|
+
* Phase 2: Layout Phase
|
|
554
|
+
*
|
|
555
|
+
* Run Yoga layout calculation and propagate dimensions to all nodes.
|
|
556
|
+
*/
|
|
557
|
+
const log$3 = createLogger("silvery:layout");
|
|
558
|
+
/**
|
|
559
|
+
* Run Yoga layout calculation and propagate dimensions to all nodes.
|
|
560
|
+
*
|
|
561
|
+
* @param root The root SilveryNode
|
|
562
|
+
* @param width Terminal width in columns
|
|
563
|
+
* @param height Terminal height in rows
|
|
564
|
+
*/
|
|
565
|
+
function layoutPhase(root, width, height) {
|
|
566
|
+
const prevLayout = root.boxRect;
|
|
567
|
+
const dimensionsChanged = prevLayout && (prevLayout.width !== width || prevLayout.height !== height);
|
|
568
|
+
if (!dimensionsChanged && !hasLayoutDirty()) return;
|
|
569
|
+
clearLayoutDirtyTracking();
|
|
570
|
+
if (root.layoutNode) {
|
|
571
|
+
const nodeCount = countNodes(root);
|
|
572
|
+
measureStats.reset();
|
|
573
|
+
const t0 = Date.now();
|
|
574
|
+
root.layoutNode.calculateLayout(width, height);
|
|
575
|
+
const elapsed = Date.now() - t0;
|
|
576
|
+
log$3.debug?.(`calculateLayout: ${elapsed}ms (${nodeCount} nodes) measure: calls=${measureStats.calls} hits=${measureStats.cacheHits} collects=${measureStats.textCollects} displayWidth=${measureStats.displayWidthCalls}`);
|
|
577
|
+
}
|
|
578
|
+
propagateLayout(root, 0, 0, !dimensionsChanged);
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Count total nodes in tree.
|
|
582
|
+
*/
|
|
583
|
+
function countNodes(node) {
|
|
584
|
+
let count = 1;
|
|
585
|
+
for (const child of node.children) count += countNodes(child);
|
|
586
|
+
return count;
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* Propagate computed layout from Yoga nodes to SilveryNodes.
|
|
590
|
+
* Sets boxRect (content-relative position) on each node.
|
|
591
|
+
*
|
|
592
|
+
* When `incrementalSkip` is true, nodes whose Flexily-computed rect matches
|
|
593
|
+
* their existing boxRect can skip the entire subtree — their layout is
|
|
594
|
+
* unchanged. This converts the O(N) tree walk into O(dirty) for frames
|
|
595
|
+
* where only a few nodes changed layout.
|
|
596
|
+
*
|
|
597
|
+
* The skip is safe because:
|
|
598
|
+
* - Flexily's internal fingerprint caching guarantees identical output for
|
|
599
|
+
* subtrees whose inputs didn't change
|
|
600
|
+
* - If the parent's rect matches, all descendants' rects also match
|
|
601
|
+
* (Flexily computes absolute positions from parent dimensions)
|
|
602
|
+
* - prevLayout, layoutDirty (already false), and layoutChangedThisFrame
|
|
603
|
+
* (stale epoch, won't match current) all retain correct values
|
|
604
|
+
*
|
|
605
|
+
* @param node The node to process
|
|
606
|
+
* @param parentX Absolute X position of parent
|
|
607
|
+
* @param parentY Absolute Y position of parent
|
|
608
|
+
* @param incrementalSkip When true, skip subtrees where Flexily results match existing boxRect
|
|
609
|
+
*/
|
|
610
|
+
function propagateLayout(node, parentX, parentY, incrementalSkip) {
|
|
611
|
+
if (!node.layoutNode) {
|
|
612
|
+
node.prevLayout = node.boxRect;
|
|
613
|
+
node.boxRect = {
|
|
614
|
+
x: parentX,
|
|
615
|
+
y: parentY,
|
|
616
|
+
width: 0,
|
|
617
|
+
height: 0
|
|
618
|
+
};
|
|
619
|
+
node.layoutDirty = false;
|
|
620
|
+
for (const child of node.children) propagateLayout(child, parentX, parentY, incrementalSkip);
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
const rect = {
|
|
624
|
+
x: parentX + node.layoutNode.getComputedLeft(),
|
|
625
|
+
y: parentY + node.layoutNode.getComputedTop(),
|
|
626
|
+
width: node.layoutNode.getComputedWidth(),
|
|
627
|
+
height: node.layoutNode.getComputedHeight()
|
|
628
|
+
};
|
|
629
|
+
if (incrementalSkip && node.boxRect && !node.layoutDirty && !isDirty(node.dirtyBits, node.dirtyEpoch, 16) && !isDirty(node.dirtyBits, node.dirtyEpoch, 8)) {
|
|
630
|
+
if (rect.x === node.boxRect.x && rect.y === node.boxRect.y && rect.width === node.boxRect.width && rect.height === node.boxRect.height) return;
|
|
631
|
+
}
|
|
632
|
+
node.prevLayout = node.boxRect;
|
|
633
|
+
node.boxRect = rect;
|
|
634
|
+
node.layoutDirty = false;
|
|
635
|
+
node.layoutChangedThisFrame = !!(node.prevLayout && !rectEqual(node.prevLayout, node.boxRect)) ? getRenderEpoch() : -1;
|
|
636
|
+
if (process?.env?.SILVERY_STRICT && isCurrentEpoch(node.layoutChangedThisFrame)) {
|
|
637
|
+
if (rectEqual(node.prevLayout, node.boxRect)) {
|
|
638
|
+
const props = node.props;
|
|
639
|
+
throw new Error(`[SILVERY_STRICT] layoutChangedThisFrame=true but prevLayout equals boxRect (node: ${props.id ?? node.type}, rect: ${JSON.stringify(node.boxRect)})`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
if (isCurrentEpoch(node.layoutChangedThisFrame)) {
|
|
643
|
+
const epoch = getRenderEpoch();
|
|
644
|
+
let ancestor = node.parent;
|
|
645
|
+
while (ancestor && !isDirty(ancestor.dirtyBits, ancestor.dirtyEpoch, 16)) {
|
|
646
|
+
if (ancestor.dirtyEpoch !== epoch) {
|
|
647
|
+
ancestor.dirtyBits = 16;
|
|
648
|
+
ancestor.dirtyEpoch = epoch;
|
|
649
|
+
} else ancestor.dirtyBits |= 16;
|
|
650
|
+
ancestor = ancestor.parent;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
for (const child of node.children) propagateLayout(child, rect.x, rect.y, incrementalSkip);
|
|
654
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, 16) && node.children.length > 0) {
|
|
655
|
+
const epoch = getRenderEpoch();
|
|
656
|
+
const absChild = _hasAbsoluteChildMutated(node.children);
|
|
657
|
+
const descOverflow = _hasDescendantOverflowChanged(node, rect);
|
|
658
|
+
let bits = node.dirtyBits;
|
|
659
|
+
if (absChild) bits |= 32;
|
|
660
|
+
else bits &= -33;
|
|
661
|
+
if (descOverflow) bits |= 64;
|
|
662
|
+
else bits &= -65;
|
|
663
|
+
node.dirtyBits = bits;
|
|
664
|
+
node.dirtyEpoch = epoch;
|
|
665
|
+
} else if (node.dirtyEpoch === getRenderEpoch()) node.dirtyBits &= -97;
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Check if any direct child is position="absolute" and had structural changes.
|
|
669
|
+
*/
|
|
670
|
+
function _hasAbsoluteChildMutated(children) {
|
|
671
|
+
for (const child of children) if (child.props.position === "absolute" && (isDirty(child.dirtyBits, child.dirtyEpoch, 8) || isCurrentEpoch(child.layoutChangedThisFrame) || _hasChildPositionChanged(child))) return true;
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Check if any child's position changed (boxRect vs prevLayout).
|
|
676
|
+
*/
|
|
677
|
+
function _hasChildPositionChanged(node) {
|
|
678
|
+
for (const child of node.children) if (child.boxRect && child.prevLayout) {
|
|
679
|
+
if (child.boxRect.x !== child.prevLayout.x || child.boxRect.y !== child.prevLayout.y) return true;
|
|
680
|
+
}
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Check if any descendant was overflowing THIS node's rect and had its layout change.
|
|
685
|
+
* Recursive: follows subtreeDirty paths for efficiency.
|
|
686
|
+
*/
|
|
687
|
+
function _hasDescendantOverflowChanged(node, rect) {
|
|
688
|
+
return _checkDescendantOverflow(node.children, rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
|
|
689
|
+
}
|
|
690
|
+
function _checkDescendantOverflow(children, nodeLeft, nodeTop, nodeRight, nodeBottom) {
|
|
691
|
+
for (const child of children) {
|
|
692
|
+
if (child.prevLayout && isCurrentEpoch(child.layoutChangedThisFrame)) {
|
|
693
|
+
const prev = child.prevLayout;
|
|
694
|
+
if (prev.x + prev.width > nodeRight || prev.y + prev.height > nodeBottom || prev.x < nodeLeft || prev.y < nodeTop) return true;
|
|
695
|
+
}
|
|
696
|
+
if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) {
|
|
697
|
+
if (_checkDescendantOverflow(child.children, nodeLeft, nodeTop, nodeRight, nodeBottom)) return true;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return false;
|
|
701
|
+
}
|
|
702
|
+
/**
|
|
703
|
+
* Notify all layout subscribers of dimension changes.
|
|
704
|
+
*
|
|
705
|
+
* Called from executeRender AFTER scrollrectPhase completes,
|
|
706
|
+
* so useScrollRect can read correct screen positions.
|
|
707
|
+
*
|
|
708
|
+
* Notifies when EITHER boxRect, scrollRect, or screenRect changed.
|
|
709
|
+
* scrollRect can change from scroll offset changes even when
|
|
710
|
+
* boxRect stays the same — subscribers (like useScrollRect)
|
|
711
|
+
* need notification in both cases. screenRect can change from sticky
|
|
712
|
+
* offset changes even when scrollRect stays the same.
|
|
713
|
+
*/
|
|
714
|
+
function notifyLayoutSubscribers(node) {
|
|
715
|
+
const contentChanged = !rectEqual(node.prevLayout, node.boxRect);
|
|
716
|
+
const screenChanged = !rectEqual(node.prevScrollRect, node.scrollRect);
|
|
717
|
+
const renderChanged = !rectEqual(node.prevScreenRect, node.screenRect);
|
|
718
|
+
if (contentChanged || screenChanged || renderChanged) for (const subscriber of node.layoutSubscribers) subscriber();
|
|
719
|
+
for (const child of node.children) notifyLayoutSubscribers(child);
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Calculate scroll state for all overflow='scroll' containers.
|
|
723
|
+
*
|
|
724
|
+
* This phase runs after layout to determine which children are visible
|
|
725
|
+
* within each scrollable container.
|
|
726
|
+
*/
|
|
727
|
+
function scrollPhase(root, options = {}) {
|
|
728
|
+
const { skipStateUpdates = false } = options;
|
|
729
|
+
traverseTree(root, (node) => {
|
|
730
|
+
const props = node.props;
|
|
731
|
+
if (props.overflow !== "scroll") return;
|
|
732
|
+
calculateScrollState(node, props, skipStateUpdates);
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Calculate scroll state for a single scrollable container.
|
|
737
|
+
*/
|
|
738
|
+
function calculateScrollState(node, props, skipStateUpdates) {
|
|
739
|
+
const layout = node.boxRect;
|
|
740
|
+
if (!layout || !node.layoutNode) return;
|
|
741
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
742
|
+
top: 0,
|
|
743
|
+
bottom: 0,
|
|
744
|
+
left: 0,
|
|
745
|
+
right: 0
|
|
746
|
+
};
|
|
747
|
+
const padding = getPadding(props);
|
|
748
|
+
const rawViewportHeight = layout.height - border.top - border.bottom - padding.top - padding.bottom;
|
|
749
|
+
let contentHeight = 0;
|
|
750
|
+
const childPositions = [];
|
|
751
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
752
|
+
const child = node.children[i];
|
|
753
|
+
if (!child.layoutNode || !child.boxRect) continue;
|
|
754
|
+
const childTop = child.boxRect.y - layout.y - border.top - padding.top;
|
|
755
|
+
const childBottom = childTop + child.boxRect.height;
|
|
756
|
+
const childProps = child.props;
|
|
757
|
+
childPositions.push({
|
|
758
|
+
child,
|
|
759
|
+
top: childTop,
|
|
760
|
+
bottom: childBottom,
|
|
761
|
+
index: i,
|
|
762
|
+
isSticky: childProps.position === "sticky",
|
|
763
|
+
stickyTop: childProps.stickyTop,
|
|
764
|
+
stickyBottom: childProps.stickyBottom
|
|
765
|
+
});
|
|
766
|
+
contentHeight = Math.max(contentHeight, childBottom);
|
|
767
|
+
}
|
|
768
|
+
const viewportHeight = rawViewportHeight;
|
|
769
|
+
const indicatorReserve = props.overflowIndicator === true && !props.borderStyle && contentHeight > rawViewportHeight ? 1 : 0;
|
|
770
|
+
const prevOffset = node.scrollState?.offset;
|
|
771
|
+
let scrollOffset = props.scrollOffset ?? prevOffset ?? 0;
|
|
772
|
+
const scrollTo = props.scrollTo;
|
|
773
|
+
if (scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length) {
|
|
774
|
+
const target = childPositions.find((c) => c.index === scrollTo);
|
|
775
|
+
if (target) {
|
|
776
|
+
const effectiveHeight = viewportHeight - indicatorReserve;
|
|
777
|
+
const visibleTop = scrollOffset;
|
|
778
|
+
const visibleBottom = scrollOffset + effectiveHeight;
|
|
779
|
+
if (target.top < visibleTop) scrollOffset = target.top;
|
|
780
|
+
else if (target.bottom > visibleBottom) scrollOffset = target.bottom - effectiveHeight;
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
scrollOffset = Math.max(0, scrollOffset);
|
|
784
|
+
scrollOffset = Math.min(scrollOffset, Math.max(0, contentHeight - viewportHeight));
|
|
785
|
+
const visibleTop = scrollOffset;
|
|
786
|
+
const visibleBottom = scrollOffset + viewportHeight - indicatorReserve;
|
|
787
|
+
let firstVisible = -1;
|
|
788
|
+
let lastVisible = -1;
|
|
789
|
+
let hiddenAbove = 0;
|
|
790
|
+
let hiddenBelow = 0;
|
|
791
|
+
for (const cp of childPositions) {
|
|
792
|
+
if (cp.isSticky) {
|
|
793
|
+
if (firstVisible === -1) firstVisible = cp.index;
|
|
794
|
+
lastVisible = Math.max(lastVisible, cp.index);
|
|
795
|
+
continue;
|
|
796
|
+
}
|
|
797
|
+
if (cp.top === cp.bottom) continue;
|
|
798
|
+
if (cp.bottom <= visibleTop) hiddenAbove++;
|
|
799
|
+
else if (cp.top >= visibleBottom) hiddenBelow++;
|
|
800
|
+
else if (cp.top < visibleTop) {
|
|
801
|
+
if (firstVisible === -1) firstVisible = cp.index;
|
|
802
|
+
lastVisible = Math.max(lastVisible, cp.index);
|
|
803
|
+
} else if (cp.bottom > visibleBottom) {
|
|
804
|
+
if (firstVisible === -1) firstVisible = cp.index;
|
|
805
|
+
lastVisible = cp.index;
|
|
806
|
+
if (indicatorReserve > 0) hiddenBelow++;
|
|
807
|
+
} else {
|
|
808
|
+
if (firstVisible === -1) firstVisible = cp.index;
|
|
809
|
+
lastVisible = cp.index;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const stickyChildren = [];
|
|
813
|
+
for (const cp of childPositions) {
|
|
814
|
+
if (!cp.isSticky) continue;
|
|
815
|
+
const childHeight = cp.bottom - cp.top;
|
|
816
|
+
const stickyTop = cp.stickyTop ?? 0;
|
|
817
|
+
const stickyBottom = cp.stickyBottom;
|
|
818
|
+
const naturalRenderY = cp.top - scrollOffset;
|
|
819
|
+
let renderOffset;
|
|
820
|
+
if (stickyBottom !== void 0) {
|
|
821
|
+
const bottomPinPosition = viewportHeight - stickyBottom - childHeight;
|
|
822
|
+
renderOffset = Math.min(naturalRenderY, bottomPinPosition);
|
|
823
|
+
} else if (naturalRenderY >= stickyTop) renderOffset = naturalRenderY;
|
|
824
|
+
else if (childHeight > viewportHeight) renderOffset = Math.max(viewportHeight - childHeight, naturalRenderY);
|
|
825
|
+
else renderOffset = stickyTop;
|
|
826
|
+
if (renderOffset !== naturalRenderY) if (childHeight > viewportHeight) renderOffset = Math.max(viewportHeight - childHeight, renderOffset);
|
|
827
|
+
else renderOffset = Math.max(0, Math.min(renderOffset, viewportHeight - childHeight));
|
|
828
|
+
if (renderOffset + childHeight <= 0 || renderOffset >= viewportHeight) continue;
|
|
829
|
+
stickyChildren.push({
|
|
830
|
+
index: cp.index,
|
|
831
|
+
renderOffset,
|
|
832
|
+
naturalTop: cp.top,
|
|
833
|
+
height: childHeight
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
if (skipStateUpdates) return;
|
|
837
|
+
const prevFirstVisible = node.scrollState?.firstVisibleChild ?? firstVisible;
|
|
838
|
+
const prevLastVisible = node.scrollState?.lastVisibleChild ?? lastVisible;
|
|
839
|
+
if (scrollOffset !== prevOffset || firstVisible !== prevFirstVisible || lastVisible !== prevLastVisible) {
|
|
840
|
+
const epoch = getRenderEpoch();
|
|
841
|
+
if (node.dirtyEpoch !== epoch) {
|
|
842
|
+
node.dirtyBits = 16;
|
|
843
|
+
node.dirtyEpoch = epoch;
|
|
844
|
+
} else node.dirtyBits |= 16;
|
|
845
|
+
}
|
|
846
|
+
node.scrollState = {
|
|
847
|
+
offset: scrollOffset,
|
|
848
|
+
prevOffset: prevOffset ?? scrollOffset,
|
|
849
|
+
contentHeight,
|
|
850
|
+
viewportHeight,
|
|
851
|
+
firstVisibleChild: firstVisible,
|
|
852
|
+
lastVisibleChild: lastVisible,
|
|
853
|
+
prevFirstVisibleChild: prevFirstVisible,
|
|
854
|
+
prevLastVisibleChild: prevLastVisible,
|
|
855
|
+
hiddenAbove,
|
|
856
|
+
hiddenBelow,
|
|
857
|
+
stickyChildren: stickyChildren.length > 0 ? stickyChildren : void 0
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Compute sticky offsets for non-scroll containers that have sticky children.
|
|
862
|
+
*
|
|
863
|
+
* Scroll containers handle their own sticky logic in calculateScrollState().
|
|
864
|
+
* This phase handles the remaining case: parents that are NOT overflow="scroll"
|
|
865
|
+
* but still contain position="sticky" children with stickyBottom.
|
|
866
|
+
*
|
|
867
|
+
* For non-scroll containers, sticky means: pin the child to the parent's bottom
|
|
868
|
+
* edge when content is shorter than the parent. When content fills the parent,
|
|
869
|
+
* the child stays at its natural position.
|
|
870
|
+
*/
|
|
871
|
+
function stickyPhase(root) {
|
|
872
|
+
traverseTree(root, (node) => {
|
|
873
|
+
const props = node.props;
|
|
874
|
+
if (props.overflow === "scroll") return;
|
|
875
|
+
let hasStickyChildren = false;
|
|
876
|
+
for (const child of node.children) {
|
|
877
|
+
const childProps = child.props;
|
|
878
|
+
if (childProps.position === "sticky" && childProps.stickyBottom !== void 0) {
|
|
879
|
+
hasStickyChildren = true;
|
|
880
|
+
break;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (!hasStickyChildren) {
|
|
884
|
+
if (node.stickyChildren !== void 0) {
|
|
885
|
+
node.stickyChildren = void 0;
|
|
886
|
+
const epoch = getRenderEpoch();
|
|
887
|
+
if (node.dirtyEpoch !== epoch) {
|
|
888
|
+
node.dirtyBits = 16;
|
|
889
|
+
node.dirtyEpoch = epoch;
|
|
890
|
+
} else node.dirtyBits |= 16;
|
|
891
|
+
}
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
const layout = node.boxRect;
|
|
895
|
+
if (!layout || !node.layoutNode) return;
|
|
896
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
897
|
+
top: 0,
|
|
898
|
+
bottom: 0,
|
|
899
|
+
left: 0,
|
|
900
|
+
right: 0
|
|
901
|
+
};
|
|
902
|
+
const padding = getPadding(props);
|
|
903
|
+
const parentContentHeight = layout.height - border.top - border.bottom - padding.top - padding.bottom;
|
|
904
|
+
const newStickyChildren = [];
|
|
905
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
906
|
+
const child = node.children[i];
|
|
907
|
+
const childProps = child.props;
|
|
908
|
+
if (childProps.position !== "sticky") continue;
|
|
909
|
+
if (childProps.stickyBottom === void 0) continue;
|
|
910
|
+
if (!child.boxRect) continue;
|
|
911
|
+
const naturalY = child.boxRect.y - layout.y - border.top - padding.top;
|
|
912
|
+
const childHeight = child.boxRect.height;
|
|
913
|
+
const bottomPin = parentContentHeight - childProps.stickyBottom - childHeight;
|
|
914
|
+
const renderOffset = Math.max(naturalY, bottomPin);
|
|
915
|
+
newStickyChildren.push({
|
|
916
|
+
index: i,
|
|
917
|
+
renderOffset,
|
|
918
|
+
naturalTop: naturalY,
|
|
919
|
+
height: childHeight
|
|
920
|
+
});
|
|
921
|
+
}
|
|
922
|
+
const prev = node.stickyChildren;
|
|
923
|
+
const next = newStickyChildren.length > 0 ? newStickyChildren : void 0;
|
|
924
|
+
const changed = !stickyChildrenEqual(prev, next);
|
|
925
|
+
node.stickyChildren = next;
|
|
926
|
+
if (changed) {
|
|
927
|
+
const epoch = getRenderEpoch();
|
|
928
|
+
if (node.dirtyEpoch !== epoch) {
|
|
929
|
+
node.dirtyBits = 16;
|
|
930
|
+
node.dirtyEpoch = epoch;
|
|
931
|
+
} else node.dirtyBits |= 16;
|
|
932
|
+
}
|
|
933
|
+
});
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Compare two stickyChildren arrays for equality.
|
|
937
|
+
*/
|
|
938
|
+
function stickyChildrenEqual(a, b) {
|
|
939
|
+
if (a === b) return true;
|
|
940
|
+
if (!a || !b) return false;
|
|
941
|
+
if (a.length !== b.length) return false;
|
|
942
|
+
for (let i = 0; i < a.length; i++) {
|
|
943
|
+
const ai = a[i];
|
|
944
|
+
const bi = b[i];
|
|
945
|
+
if (ai.index !== bi.index || ai.renderOffset !== bi.renderOffset || ai.naturalTop !== bi.naturalTop || ai.height !== bi.height) return false;
|
|
946
|
+
}
|
|
947
|
+
return true;
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Traverse tree in depth-first order.
|
|
951
|
+
*/
|
|
952
|
+
function traverseTree(node, callback) {
|
|
953
|
+
callback(node);
|
|
954
|
+
for (const child of node.children) traverseTree(child, callback);
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Calculate screen-relative positions for all nodes.
|
|
958
|
+
*
|
|
959
|
+
* This phase runs after scroll phase to compute where each node actually
|
|
960
|
+
* appears on the terminal screen, accounting for all ancestor scroll offsets.
|
|
961
|
+
*
|
|
962
|
+
* Also computes `screenRect` which accounts for sticky render offsets.
|
|
963
|
+
* For non-sticky nodes, screenRect === scrollRect. For sticky nodes,
|
|
964
|
+
* screenRect reflects the actual pixel position where the node is painted.
|
|
965
|
+
*
|
|
966
|
+
* Screen position = content position - sum of ancestor scroll offsets
|
|
967
|
+
*/
|
|
968
|
+
function scrollrectPhase(root) {
|
|
969
|
+
propagateScrollRect(root, 0);
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Fast path for scrollrectPhase when no scroll containers or sticky nodes exist.
|
|
973
|
+
*
|
|
974
|
+
* When there are no scroll containers and no sticky nodes, ancestorScrollOffset
|
|
975
|
+
* is always 0, so scrollRect === boxRect and screenRect === scrollRect. This
|
|
976
|
+
* avoids the overhead of accumulating scroll offsets through the tree.
|
|
977
|
+
*/
|
|
978
|
+
function scrollrectPhaseSimple(root) {
|
|
979
|
+
propagateScrollRectSimple(root);
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Propagate screen-relative positions through the tree.
|
|
983
|
+
*
|
|
984
|
+
* @param node The node to process
|
|
985
|
+
* @param ancestorScrollOffset Sum of all ancestor scroll offsets
|
|
986
|
+
*/
|
|
987
|
+
function propagateScrollRect(node, ancestorScrollOffset) {
|
|
988
|
+
node.prevScrollRect = node.scrollRect;
|
|
989
|
+
node.prevScreenRect = node.screenRect;
|
|
990
|
+
const content = node.boxRect;
|
|
991
|
+
if (!content) {
|
|
992
|
+
node.scrollRect = null;
|
|
993
|
+
node.screenRect = null;
|
|
994
|
+
for (const child of node.children) propagateScrollRect(child, ancestorScrollOffset);
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
node.scrollRect = {
|
|
998
|
+
x: content.x,
|
|
999
|
+
y: content.y - ancestorScrollOffset,
|
|
1000
|
+
width: content.width,
|
|
1001
|
+
height: content.height
|
|
1002
|
+
};
|
|
1003
|
+
node.screenRect = node.scrollRect;
|
|
1004
|
+
const childScrollOffset = ancestorScrollOffset + (node.scrollState?.offset ?? 0);
|
|
1005
|
+
computeStickyScreenRects(node);
|
|
1006
|
+
for (const child of node.children) propagateScrollRect(child, childScrollOffset);
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* Compute screenRect for sticky children of a node.
|
|
1010
|
+
*
|
|
1011
|
+
* For sticky children, the actual render position differs from the layout
|
|
1012
|
+
* position (scrollRect). The renderOffset from the scroll/sticky phase
|
|
1013
|
+
* determines where pixels are actually painted. This function sets
|
|
1014
|
+
* screenRect on those children to reflect the true screen position.
|
|
1015
|
+
*
|
|
1016
|
+
* @param parent The parent node whose sticky children need screenRect computation
|
|
1017
|
+
*/
|
|
1018
|
+
function computeStickyScreenRects(parent) {
|
|
1019
|
+
const stickyList = parent.scrollState?.stickyChildren ?? parent.stickyChildren;
|
|
1020
|
+
if (!stickyList || stickyList.length === 0) return;
|
|
1021
|
+
const parentScrollRect = parent.scrollRect;
|
|
1022
|
+
if (!parentScrollRect) return;
|
|
1023
|
+
const props = parent.props;
|
|
1024
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
1025
|
+
top: 0,
|
|
1026
|
+
bottom: 0,
|
|
1027
|
+
left: 0,
|
|
1028
|
+
right: 0
|
|
1029
|
+
};
|
|
1030
|
+
const padding = getPadding(props);
|
|
1031
|
+
const contentOriginY = parentScrollRect.y + border.top + padding.top;
|
|
1032
|
+
for (const sticky of stickyList) {
|
|
1033
|
+
const child = parent.children[sticky.index];
|
|
1034
|
+
if (!child?.scrollRect) continue;
|
|
1035
|
+
child.screenRect = {
|
|
1036
|
+
x: child.scrollRect.x,
|
|
1037
|
+
y: contentOriginY + sticky.renderOffset,
|
|
1038
|
+
width: child.scrollRect.width,
|
|
1039
|
+
height: child.scrollRect.height
|
|
1040
|
+
};
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Simple scrollRect propagation for trees without scroll containers or sticky nodes.
|
|
1045
|
+
* When ancestorScrollOffset is always 0, scrollRect === boxRect and screenRect === scrollRect.
|
|
1046
|
+
* Saves the overhead of accumulating scroll offsets and computing sticky screen rects.
|
|
1047
|
+
*/
|
|
1048
|
+
function propagateScrollRectSimple(node) {
|
|
1049
|
+
node.prevScrollRect = node.scrollRect;
|
|
1050
|
+
node.prevScreenRect = node.screenRect;
|
|
1051
|
+
const content = node.boxRect;
|
|
1052
|
+
if (!content) {
|
|
1053
|
+
node.scrollRect = null;
|
|
1054
|
+
node.screenRect = null;
|
|
1055
|
+
for (const child of node.children) propagateScrollRectSimple(child);
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
node.scrollRect = {
|
|
1059
|
+
x: content.x,
|
|
1060
|
+
y: content.y,
|
|
1061
|
+
width: content.width,
|
|
1062
|
+
height: content.height
|
|
1063
|
+
};
|
|
1064
|
+
node.screenRect = node.scrollRect;
|
|
1065
|
+
for (const child of node.children) propagateScrollRectSimple(child);
|
|
1066
|
+
}
|
|
1067
|
+
/**
|
|
1068
|
+
* Scan the tree for features that require optional pipeline phases.
|
|
1069
|
+
*
|
|
1070
|
+
* Returns feature flags. This is called on every layout pass so newly
|
|
1071
|
+
* mounted components are detected. The caller should merge flags with
|
|
1072
|
+
* one-way semantics (false → true, never true → false).
|
|
1073
|
+
*/
|
|
1074
|
+
function detectPipelineFeatures(root) {
|
|
1075
|
+
let hasScroll = false;
|
|
1076
|
+
let hasSticky = false;
|
|
1077
|
+
function scan(node) {
|
|
1078
|
+
const props = node.props;
|
|
1079
|
+
if (props.overflow === "scroll") hasScroll = true;
|
|
1080
|
+
if (props.position === "sticky") hasSticky = true;
|
|
1081
|
+
if (hasScroll && hasSticky) return;
|
|
1082
|
+
for (const child of node.children) {
|
|
1083
|
+
scan(child);
|
|
1084
|
+
if (hasScroll && hasSticky) return;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
scan(root);
|
|
1088
|
+
return {
|
|
1089
|
+
hasScroll,
|
|
1090
|
+
hasSticky
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region packages/ag-term/src/pipeline/render-helpers.ts
|
|
1095
|
+
init_buffer();
|
|
1096
|
+
init_state();
|
|
1097
|
+
init_resolve();
|
|
1098
|
+
const namedColors = {
|
|
1099
|
+
black: 0,
|
|
1100
|
+
red: 1,
|
|
1101
|
+
green: 2,
|
|
1102
|
+
yellow: 3,
|
|
1103
|
+
blue: 4,
|
|
1104
|
+
magenta: 5,
|
|
1105
|
+
cyan: 6,
|
|
1106
|
+
white: 7,
|
|
1107
|
+
gray: 8,
|
|
1108
|
+
grey: 8,
|
|
1109
|
+
blackBright: 8,
|
|
1110
|
+
redBright: 9,
|
|
1111
|
+
greenBright: 10,
|
|
1112
|
+
yellowBright: 11,
|
|
1113
|
+
blueBright: 12,
|
|
1114
|
+
magentaBright: 13,
|
|
1115
|
+
cyanBright: 14,
|
|
1116
|
+
whiteBright: 15
|
|
1117
|
+
};
|
|
1118
|
+
/**
|
|
1119
|
+
* Blend two RGB colors in sRGB space.
|
|
1120
|
+
* Formula: result = c1 * (1 - t) + c2 * t, where t is 0..1.
|
|
1121
|
+
* Returns an RGB object with each channel clamped to 0-255.
|
|
1122
|
+
*/
|
|
1123
|
+
function blendColors(c1, c2, t) {
|
|
1124
|
+
return {
|
|
1125
|
+
r: Math.round(c1.r * (1 - t) + c2.r * t),
|
|
1126
|
+
g: Math.round(c1.g * (1 - t) + c2.g * t),
|
|
1127
|
+
b: Math.round(c1.b * (1 - t) + c2.b * t)
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Parse color string to Color type.
|
|
1132
|
+
* Supports: mix(c1,c2,amount), $token (theme), named colors, hex (#rgb, #rrggbb), rgb(r,g,b)
|
|
1133
|
+
*/
|
|
1134
|
+
function parseColor(color) {
|
|
1135
|
+
if (color === "inherit") return null;
|
|
1136
|
+
if (color === "$default") return DEFAULT_BG;
|
|
1137
|
+
if (color.startsWith("mix(") && color.endsWith(")")) {
|
|
1138
|
+
const inner = color.slice(4, -1);
|
|
1139
|
+
const args = [];
|
|
1140
|
+
let depth = 0;
|
|
1141
|
+
let start = 0;
|
|
1142
|
+
for (let i = 0; i < inner.length; i++) if (inner[i] === "(") depth++;
|
|
1143
|
+
else if (inner[i] === ")") depth--;
|
|
1144
|
+
else if (inner[i] === "," && depth === 0) {
|
|
1145
|
+
args.push(inner.slice(start, i).trim());
|
|
1146
|
+
start = i + 1;
|
|
1147
|
+
}
|
|
1148
|
+
args.push(inner.slice(start).trim());
|
|
1149
|
+
if (args.length === 3) {
|
|
1150
|
+
const c1 = parseColor(args[0]);
|
|
1151
|
+
const c2 = parseColor(args[1]);
|
|
1152
|
+
const amountStr = args[2];
|
|
1153
|
+
let t;
|
|
1154
|
+
if (amountStr.endsWith("%")) t = Number.parseFloat(amountStr.slice(0, -1)) / 100;
|
|
1155
|
+
else t = Number.parseFloat(amountStr);
|
|
1156
|
+
if (c1 !== null && c2 !== null && typeof c1 === "object" && typeof c2 === "object" && !Number.isNaN(t)) return blendColors(c1, c2, Math.max(0, Math.min(1, t)));
|
|
1157
|
+
return null;
|
|
1158
|
+
}
|
|
1159
|
+
}
|
|
1160
|
+
if (color.startsWith("$")) {
|
|
1161
|
+
const resolved = resolveThemeColor(color, getActiveTheme());
|
|
1162
|
+
if (resolved && resolved !== color) return parseColor(resolved);
|
|
1163
|
+
return null;
|
|
1164
|
+
}
|
|
1165
|
+
if (color in namedColors) return namedColors[color];
|
|
1166
|
+
if (color.startsWith("#")) {
|
|
1167
|
+
const hex = color.slice(1);
|
|
1168
|
+
if (hex.length === 3) return {
|
|
1169
|
+
r: Number.parseInt(hex[0] + hex[0], 16),
|
|
1170
|
+
g: Number.parseInt(hex[1] + hex[1], 16),
|
|
1171
|
+
b: Number.parseInt(hex[2] + hex[2], 16)
|
|
1172
|
+
};
|
|
1173
|
+
if (hex.length === 6) return {
|
|
1174
|
+
r: Number.parseInt(hex.slice(0, 2), 16),
|
|
1175
|
+
g: Number.parseInt(hex.slice(2, 4), 16),
|
|
1176
|
+
b: Number.parseInt(hex.slice(4, 6), 16)
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
const rgbMatch = color.match(/^rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/i);
|
|
1180
|
+
if (rgbMatch) return {
|
|
1181
|
+
r: Number.parseInt(rgbMatch[1], 10),
|
|
1182
|
+
g: Number.parseInt(rgbMatch[2], 10),
|
|
1183
|
+
b: Number.parseInt(rgbMatch[3], 10)
|
|
1184
|
+
};
|
|
1185
|
+
const ansi256Match = color.match(/^ansi256\s*\(\s*(\d+)\s*\)$/i);
|
|
1186
|
+
if (ansi256Match) return Number.parseInt(ansi256Match[1], 10);
|
|
1187
|
+
return null;
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Border character sets by style. Hoisted to module scope to avoid
|
|
1191
|
+
* re-allocating 7 objects on every call.
|
|
1192
|
+
*/
|
|
1193
|
+
const borders = {
|
|
1194
|
+
single: {
|
|
1195
|
+
topLeft: "┌",
|
|
1196
|
+
topRight: "┐",
|
|
1197
|
+
bottomLeft: "└",
|
|
1198
|
+
bottomRight: "┘",
|
|
1199
|
+
horizontal: "─",
|
|
1200
|
+
vertical: "│"
|
|
1201
|
+
},
|
|
1202
|
+
double: {
|
|
1203
|
+
topLeft: "╔",
|
|
1204
|
+
topRight: "╗",
|
|
1205
|
+
bottomLeft: "╚",
|
|
1206
|
+
bottomRight: "╝",
|
|
1207
|
+
horizontal: "═",
|
|
1208
|
+
vertical: "║"
|
|
1209
|
+
},
|
|
1210
|
+
round: {
|
|
1211
|
+
topLeft: "╭",
|
|
1212
|
+
topRight: "╮",
|
|
1213
|
+
bottomLeft: "╰",
|
|
1214
|
+
bottomRight: "╯",
|
|
1215
|
+
horizontal: "─",
|
|
1216
|
+
vertical: "│"
|
|
1217
|
+
},
|
|
1218
|
+
bold: {
|
|
1219
|
+
topLeft: "┏",
|
|
1220
|
+
topRight: "┓",
|
|
1221
|
+
bottomLeft: "┗",
|
|
1222
|
+
bottomRight: "┛",
|
|
1223
|
+
horizontal: "━",
|
|
1224
|
+
vertical: "┃"
|
|
1225
|
+
},
|
|
1226
|
+
singleDouble: {
|
|
1227
|
+
topLeft: "╓",
|
|
1228
|
+
topRight: "╖",
|
|
1229
|
+
bottomLeft: "╙",
|
|
1230
|
+
bottomRight: "╜",
|
|
1231
|
+
horizontal: "─",
|
|
1232
|
+
vertical: "║"
|
|
1233
|
+
},
|
|
1234
|
+
doubleSingle: {
|
|
1235
|
+
topLeft: "╒",
|
|
1236
|
+
topRight: "╕",
|
|
1237
|
+
bottomLeft: "╘",
|
|
1238
|
+
bottomRight: "╛",
|
|
1239
|
+
horizontal: "═",
|
|
1240
|
+
vertical: "│"
|
|
1241
|
+
},
|
|
1242
|
+
classic: {
|
|
1243
|
+
topLeft: "+",
|
|
1244
|
+
topRight: "+",
|
|
1245
|
+
bottomLeft: "+",
|
|
1246
|
+
bottomRight: "+",
|
|
1247
|
+
horizontal: "-",
|
|
1248
|
+
vertical: "|"
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
/**
|
|
1252
|
+
* Get border characters for a style.
|
|
1253
|
+
*/
|
|
1254
|
+
function getBorderChars(style) {
|
|
1255
|
+
if (style && typeof style === "object") {
|
|
1256
|
+
const obj = style;
|
|
1257
|
+
const topHorizontal = obj.top ?? obj.horizontal ?? "-";
|
|
1258
|
+
const leftVertical = obj.left ?? obj.vertical ?? "|";
|
|
1259
|
+
return {
|
|
1260
|
+
topLeft: obj.topLeft ?? "+",
|
|
1261
|
+
topRight: obj.topRight ?? "+",
|
|
1262
|
+
bottomLeft: obj.bottomLeft ?? "+",
|
|
1263
|
+
bottomRight: obj.bottomRight ?? "+",
|
|
1264
|
+
horizontal: topHorizontal,
|
|
1265
|
+
vertical: leftVertical,
|
|
1266
|
+
bottomHorizontal: obj.bottom && obj.bottom !== topHorizontal ? obj.bottom : void 0,
|
|
1267
|
+
rightVertical: obj.right && obj.right !== leftVertical ? obj.right : void 0
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
return borders[style ?? "single"];
|
|
1271
|
+
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Get text style from props.
|
|
1274
|
+
*/
|
|
1275
|
+
function getTextStyle(props) {
|
|
1276
|
+
let underlineStyle;
|
|
1277
|
+
if (props.underlineStyle !== void 0) underlineStyle = props.underlineStyle;
|
|
1278
|
+
else if (props.underline) underlineStyle = "single";
|
|
1279
|
+
return {
|
|
1280
|
+
fg: props.color ? parseColor(props.color) : null,
|
|
1281
|
+
bg: props.backgroundColor ? parseColor(props.backgroundColor) : null,
|
|
1282
|
+
underlineColor: props.underlineColor ? parseColor(props.underlineColor) : null,
|
|
1283
|
+
attrs: {
|
|
1284
|
+
bold: props.bold,
|
|
1285
|
+
dim: props.dim || props.dimColor,
|
|
1286
|
+
italic: props.italic,
|
|
1287
|
+
underline: props.underline || !!underlineStyle,
|
|
1288
|
+
underlineStyle,
|
|
1289
|
+
strikethrough: props.strikethrough,
|
|
1290
|
+
inverse: props.inverse
|
|
1291
|
+
}
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Get text display width (accounting for wide characters and ANSI codes).
|
|
1296
|
+
* Uses ANSI-aware width calculation to handle styled text.
|
|
1297
|
+
*
|
|
1298
|
+
* When a PipelineContext is provided, uses the context's measurer for
|
|
1299
|
+
* terminal-capability-aware width calculation. Falls back to the module-level
|
|
1300
|
+
* displayWidthAnsi (which reads the scoped measurer or default).
|
|
1301
|
+
*/
|
|
1302
|
+
function getTextWidth(text, ctx) {
|
|
1303
|
+
if (ctx) return ctx.measurer.displayWidthAnsi(text);
|
|
1304
|
+
return displayWidthAnsi(text);
|
|
1305
|
+
}
|
|
1306
|
+
//#endregion
|
|
1307
|
+
//#region packages/ag-term/src/pipeline/render-text.ts
|
|
1308
|
+
init_buffer();
|
|
1309
|
+
const log$2 = createLogger("silvery:content");
|
|
1310
|
+
/** Cached bg conflict mode. Read from env once at module load. */
|
|
1311
|
+
let bgConflictMode = (() => {
|
|
1312
|
+
const env = typeof process !== "undefined" ? process.env.SILVERY_BG_CONFLICT?.toLowerCase() : void 0;
|
|
1313
|
+
if (env === "ignore" || env === "warn" || env === "throw") return env;
|
|
1314
|
+
return "throw";
|
|
1315
|
+
})();
|
|
1316
|
+
/**
|
|
1317
|
+
* Get the current background conflict detection mode.
|
|
1318
|
+
*/
|
|
1319
|
+
function getBgConflictMode() {
|
|
1320
|
+
return bgConflictMode;
|
|
1321
|
+
}
|
|
1322
|
+
const warnedBgConflicts = /* @__PURE__ */ new Set();
|
|
1323
|
+
/** Format a Color value for bg conflict diagnostics */
|
|
1324
|
+
function formatBgConflictColor(c) {
|
|
1325
|
+
if (c === null || c === void 0) return "none";
|
|
1326
|
+
if (typeof c === "number") {
|
|
1327
|
+
if (c & 16777216) {
|
|
1328
|
+
const r = c >> 16 & 255;
|
|
1329
|
+
const g = c >> 8 & 255;
|
|
1330
|
+
const b = c & 255;
|
|
1331
|
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
1332
|
+
}
|
|
1333
|
+
return {
|
|
1334
|
+
40: "black",
|
|
1335
|
+
41: "red",
|
|
1336
|
+
42: "green",
|
|
1337
|
+
43: "yellow",
|
|
1338
|
+
44: "blue",
|
|
1339
|
+
45: "magenta",
|
|
1340
|
+
46: "cyan",
|
|
1341
|
+
47: "white",
|
|
1342
|
+
100: "brightBlack",
|
|
1343
|
+
101: "brightRed",
|
|
1344
|
+
102: "brightGreen",
|
|
1345
|
+
103: "brightYellow",
|
|
1346
|
+
104: "brightBlue",
|
|
1347
|
+
105: "brightMagenta",
|
|
1348
|
+
106: "brightCyan",
|
|
1349
|
+
107: "brightWhite"
|
|
1350
|
+
}[c] ?? `palette(${c})`;
|
|
1351
|
+
}
|
|
1352
|
+
return `rgb(${c.r},${c.g},${c.b})`;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Clear the background conflict warning cache.
|
|
1356
|
+
* Call this at the start of each render cycle to:
|
|
1357
|
+
* - Prevent memory leaks in long-running apps
|
|
1358
|
+
* - Allow warnings to repeat after user fixes issues
|
|
1359
|
+
*/
|
|
1360
|
+
function clearBgConflictWarnings() {
|
|
1361
|
+
warnedBgConflicts.clear();
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* Build ANSI escape sequence for a style context.
|
|
1365
|
+
*
|
|
1366
|
+
* Note: backgroundColor is intentionally NOT embedded as ANSI codes.
|
|
1367
|
+
* Background color is handled at the buffer level (via BgSegment tracking)
|
|
1368
|
+
* to prevent bg bleed across wrapped text lines. See km-silvery.bg-bleed.
|
|
1369
|
+
*/
|
|
1370
|
+
function styleToAnsi(style) {
|
|
1371
|
+
const parts = [];
|
|
1372
|
+
if (style.color) {
|
|
1373
|
+
const color = parseColor(style.color);
|
|
1374
|
+
if (color !== null) if (typeof color === "number") parts.push(`38;5;${color}`);
|
|
1375
|
+
else parts.push(`38;2;${color.r};${color.g};${color.b}`);
|
|
1376
|
+
}
|
|
1377
|
+
if (style.bold) parts.push("1");
|
|
1378
|
+
if (style.dim) parts.push("2");
|
|
1379
|
+
if (style.italic) parts.push("3");
|
|
1380
|
+
if (style.underlineStyle) parts.push({
|
|
1381
|
+
single: "4:1",
|
|
1382
|
+
double: "4:2",
|
|
1383
|
+
curly: "4:3",
|
|
1384
|
+
dotted: "4:4",
|
|
1385
|
+
dashed: "4:5"
|
|
1386
|
+
}[style.underlineStyle] ?? "4");
|
|
1387
|
+
else if (style.underline) parts.push("4");
|
|
1388
|
+
if (style.underlineColor) {
|
|
1389
|
+
const ulColor = parseColor(style.underlineColor);
|
|
1390
|
+
if (ulColor !== null) if (typeof ulColor === "number") parts.push(`58;5;${ulColor}`);
|
|
1391
|
+
else parts.push(`58;2;${ulColor.r};${ulColor.g};${ulColor.b}`);
|
|
1392
|
+
}
|
|
1393
|
+
if (style.inverse) parts.push("7");
|
|
1394
|
+
if (style.strikethrough) parts.push("9");
|
|
1395
|
+
if (parts.length === 0) return "";
|
|
1396
|
+
return `\x1b[${parts.join(";")}m`;
|
|
1397
|
+
}
|
|
1398
|
+
/**
|
|
1399
|
+
* Merge child props into parent context.
|
|
1400
|
+
* Child values override parent values when specified.
|
|
1401
|
+
*/
|
|
1402
|
+
function mergeStyleContext(parent, childProps) {
|
|
1403
|
+
return {
|
|
1404
|
+
color: childProps.color ?? parent.color,
|
|
1405
|
+
backgroundColor: childProps.backgroundColor ?? parent.backgroundColor,
|
|
1406
|
+
bold: childProps.bold ?? parent.bold,
|
|
1407
|
+
dim: childProps.dim ?? childProps.dimColor ?? parent.dim,
|
|
1408
|
+
italic: childProps.italic ?? parent.italic,
|
|
1409
|
+
underline: childProps.underline ?? childProps.underlineStyle ? true : parent.underline,
|
|
1410
|
+
underlineStyle: childProps.underlineStyle ?? parent.underlineStyle,
|
|
1411
|
+
underlineColor: childProps.underlineColor ?? parent.underlineColor,
|
|
1412
|
+
inverse: childProps.inverse ?? parent.inverse,
|
|
1413
|
+
strikethrough: childProps.strikethrough ?? parent.strikethrough
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Apply text styles as ANSI escape codes with proper push/pop behavior.
|
|
1418
|
+
* After the child text, restores the parent context's styles.
|
|
1419
|
+
*
|
|
1420
|
+
* @param text - The text content to wrap
|
|
1421
|
+
* @param childStyle - The merged style for this child (child overrides parent)
|
|
1422
|
+
* @param parentStyle - The parent's style context to restore after
|
|
1423
|
+
*/
|
|
1424
|
+
function applyTextStyleAnsi(text, childStyle, parentStyle) {
|
|
1425
|
+
if (!text) return text;
|
|
1426
|
+
const childAnsi = styleToAnsi(childStyle);
|
|
1427
|
+
const parentAnsi = styleToAnsi(parentStyle);
|
|
1428
|
+
if (!childAnsi) return text;
|
|
1429
|
+
return `${childAnsi}${text}\x1b[0m${parentAnsi}`;
|
|
1430
|
+
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Collect text content and background color segments from a node tree.
|
|
1433
|
+
*
|
|
1434
|
+
* Like collectTextContent, but also tracks backgroundColor from nested Text
|
|
1435
|
+
* elements as separate BgSegment entries. Background is NOT embedded as ANSI
|
|
1436
|
+
* codes, preventing bg bleed when text wraps across lines.
|
|
1437
|
+
*
|
|
1438
|
+
* @param node - The node to collect text from
|
|
1439
|
+
* @param parentContext - The inherited style context from parent
|
|
1440
|
+
* @param offset - Current character offset in the collected text (for bg tracking)
|
|
1441
|
+
* @param maxDisplayWidth - Maximum display width (columns) to collect. When set,
|
|
1442
|
+
* stops collecting once this many display columns of content have been gathered.
|
|
1443
|
+
* This truncates at the DOM level BEFORE ANSI serialization, so escape sequences
|
|
1444
|
+
* (OSC 8, etc.) are never generated for content that won't be displayed.
|
|
1445
|
+
* Uses getTextWidth (ANSI-aware) so pre-styled leaf text is handled correctly.
|
|
1446
|
+
*/
|
|
1447
|
+
function collectTextWithBg(node, parentContext = {}, offset = 0, maxDisplayWidth, ctx) {
|
|
1448
|
+
if (node.textContent !== void 0) {
|
|
1449
|
+
let text = node.textContent;
|
|
1450
|
+
if (maxDisplayWidth !== void 0) {
|
|
1451
|
+
if (getTextWidth(text, ctx) > maxDisplayWidth) text = (ctx ? ctx.measurer.sliceByWidth : sliceByWidth)(text, maxDisplayWidth);
|
|
1452
|
+
}
|
|
1453
|
+
const plainLen = getTextWidth(text, ctx);
|
|
1454
|
+
return {
|
|
1455
|
+
text,
|
|
1456
|
+
bgSegments: [],
|
|
1457
|
+
childSpans: [],
|
|
1458
|
+
plainLen
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
let result = "";
|
|
1462
|
+
const bgSegments = [];
|
|
1463
|
+
const childSpans = [];
|
|
1464
|
+
let currentOffset = offset;
|
|
1465
|
+
let displayWidthCollected = 0;
|
|
1466
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
1467
|
+
const child = node.children[i];
|
|
1468
|
+
if (maxDisplayWidth !== void 0 && displayWidthCollected >= maxDisplayWidth) break;
|
|
1469
|
+
const childBudget = maxDisplayWidth !== void 0 ? maxDisplayWidth - displayWidthCollected : void 0;
|
|
1470
|
+
if (child.type === "silvery-text" && child.props && !child.layoutNode) {
|
|
1471
|
+
const childProps = child.props;
|
|
1472
|
+
const childContext = mergeStyleContext(parentContext, childProps);
|
|
1473
|
+
const childResult = collectTextWithBg(child, childContext, currentOffset, childBudget, ctx);
|
|
1474
|
+
const childTransform = childProps.internal_transform;
|
|
1475
|
+
if (childTransform && childResult.text.length > 0) childResult.text = childTransform(childResult.text, i);
|
|
1476
|
+
const styledText = applyTextStyleAnsi(childResult.text, childContext, parentContext);
|
|
1477
|
+
result += styledText;
|
|
1478
|
+
if (childContext.backgroundColor) {
|
|
1479
|
+
const bg = parseColor(childContext.backgroundColor);
|
|
1480
|
+
if (bg !== null) {
|
|
1481
|
+
if (childResult.plainLen > 0) bgSegments.push({
|
|
1482
|
+
start: currentOffset,
|
|
1483
|
+
end: currentOffset + childResult.plainLen,
|
|
1484
|
+
bg
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
} else if (childProps.backgroundColor === "" && childResult.plainLen > 0) bgSegments.push({
|
|
1488
|
+
start: currentOffset,
|
|
1489
|
+
end: currentOffset + childResult.plainLen,
|
|
1490
|
+
bg: null
|
|
1491
|
+
});
|
|
1492
|
+
if (childResult.plainLen > 0) childSpans.push({
|
|
1493
|
+
node: child,
|
|
1494
|
+
start: currentOffset,
|
|
1495
|
+
end: currentOffset + childResult.plainLen
|
|
1496
|
+
});
|
|
1497
|
+
bgSegments.push(...childResult.bgSegments);
|
|
1498
|
+
childSpans.push(...childResult.childSpans);
|
|
1499
|
+
currentOffset += childResult.plainLen;
|
|
1500
|
+
displayWidthCollected += childResult.plainLen;
|
|
1501
|
+
} else {
|
|
1502
|
+
const childResult = collectTextWithBg(child, parentContext, currentOffset, childBudget, ctx);
|
|
1503
|
+
result += childResult.text;
|
|
1504
|
+
bgSegments.push(...childResult.bgSegments);
|
|
1505
|
+
childSpans.push(...childResult.childSpans);
|
|
1506
|
+
currentOffset += childResult.plainLen;
|
|
1507
|
+
displayWidthCollected += childResult.plainLen;
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
text: result,
|
|
1512
|
+
bgSegments,
|
|
1513
|
+
childSpans,
|
|
1514
|
+
plainLen: displayWidthCollected
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Apply background segments to buffer cells for a single rendered line.
|
|
1519
|
+
*
|
|
1520
|
+
* Maps character offsets from the original collected text to screen positions,
|
|
1521
|
+
* accounting for text wrapping. Each bg segment fills only the cells that
|
|
1522
|
+
* correspond to actual text characters, not trailing whitespace.
|
|
1523
|
+
*
|
|
1524
|
+
* @param buffer - The terminal buffer to write to
|
|
1525
|
+
* @param x - Screen x position of the line start
|
|
1526
|
+
* @param y - Screen y position of the line
|
|
1527
|
+
* @param lineText - The rendered line text (may contain ANSI codes)
|
|
1528
|
+
* @param lineCharStart - Character offset in original text where this line starts
|
|
1529
|
+
* @param lineCharEnd - Character offset in original text where this line ends
|
|
1530
|
+
* @param bgSegments - Background color segments to apply
|
|
1531
|
+
*/
|
|
1532
|
+
function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEnd, bgSegments, ctx) {
|
|
1533
|
+
if (bgSegments.length === 0) return;
|
|
1534
|
+
if (y < 0 || y >= buffer.height) return;
|
|
1535
|
+
const bgCell = createMutableCell();
|
|
1536
|
+
const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth;
|
|
1537
|
+
for (const seg of bgSegments) {
|
|
1538
|
+
const overlapStart = Math.max(seg.start, lineCharStart);
|
|
1539
|
+
const overlapEnd = Math.min(seg.end, lineCharEnd);
|
|
1540
|
+
if (overlapStart >= overlapEnd) continue;
|
|
1541
|
+
const relStart = overlapStart - lineCharStart;
|
|
1542
|
+
const relEnd = overlapEnd - lineCharStart;
|
|
1543
|
+
let col = x;
|
|
1544
|
+
const graphemes = splitGraphemes(hasAnsi(lineText) ? stripAnsiForBg(lineText) : lineText);
|
|
1545
|
+
for (const grapheme of graphemes) {
|
|
1546
|
+
const gWidth = gWidthFn(grapheme);
|
|
1547
|
+
if (gWidth === 0) continue;
|
|
1548
|
+
const displayOffset = col - x;
|
|
1549
|
+
if (displayOffset >= relStart && displayOffset < relEnd) {
|
|
1550
|
+
buffer.readCellInto(col, y, bgCell);
|
|
1551
|
+
bgCell.bg = seg.bg;
|
|
1552
|
+
buffer.setCell(col, y, bgCell);
|
|
1553
|
+
if (gWidth === 2 && col + 1 < buffer.width) {
|
|
1554
|
+
buffer.readCellInto(col + 1, y, bgCell);
|
|
1555
|
+
bgCell.bg = seg.bg;
|
|
1556
|
+
buffer.setCell(col + 1, y, bgCell);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
col += gWidth;
|
|
1560
|
+
if (col - x >= relEnd) break;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Strip ANSI escape codes from text for character counting.
|
|
1566
|
+
*/
|
|
1567
|
+
function stripAnsiForBg(text) {
|
|
1568
|
+
return text.replace(/\x1b\[[0-9;:?]*[A-Za-z]/g, "").replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "").replace(/\x1b[DME78]/g, "").replace(/\x1b\(B/g, "");
|
|
1569
|
+
}
|
|
1570
|
+
/**
|
|
1571
|
+
* Map formatted lines back to character offsets in the original text.
|
|
1572
|
+
*
|
|
1573
|
+
* After wrapping/truncation, each output line corresponds to a range
|
|
1574
|
+
* of characters in the original text. This function computes those ranges
|
|
1575
|
+
* by searching for each line's content in the normalized text.
|
|
1576
|
+
*
|
|
1577
|
+
* Handles characters consumed by word wrapping (spaces at break points,
|
|
1578
|
+
* newlines) and characters added by truncation (ellipsis).
|
|
1579
|
+
*
|
|
1580
|
+
* Returns display-width offsets (not UTF-16 code units) to match BgSegment
|
|
1581
|
+
* coordinate system. BgSegments use display-width via getTextWidth/plainLen.
|
|
1582
|
+
*
|
|
1583
|
+
* @param originalText - The original collected text (with ANSI, before wrapping)
|
|
1584
|
+
* @param formattedLines - The wrapped/truncated output lines
|
|
1585
|
+
* @param ctx - Pipeline context for width measurement
|
|
1586
|
+
* @returns Array of { start, end } display-width offsets for each formatted line
|
|
1587
|
+
*/
|
|
1588
|
+
function mapLinesToCharOffsets(originalText, formattedLines, ctx) {
|
|
1589
|
+
const normalized = (hasAnsi(originalText) ? stripAnsiForBg(originalText) : originalText).replace(/\t/g, " ");
|
|
1590
|
+
const result = [];
|
|
1591
|
+
let charOffset = 0;
|
|
1592
|
+
let displayOffset = 0;
|
|
1593
|
+
for (const line of formattedLines) {
|
|
1594
|
+
const plainLine = hasAnsi(line) ? stripAnsiForBg(line) : line;
|
|
1595
|
+
const lineStart = findLineStart(normalized, plainLine, charOffset);
|
|
1596
|
+
if (lineStart > charOffset) {
|
|
1597
|
+
const skipped = normalized.slice(charOffset, lineStart);
|
|
1598
|
+
displayOffset += getTextWidth(skipped, ctx);
|
|
1599
|
+
}
|
|
1600
|
+
const lineDisplayWidth = getTextWidth(plainLine, ctx);
|
|
1601
|
+
result.push({
|
|
1602
|
+
start: displayOffset,
|
|
1603
|
+
end: displayOffset + lineDisplayWidth
|
|
1604
|
+
});
|
|
1605
|
+
charOffset = lineStart + Math.min(plainLine.length, normalized.length - lineStart);
|
|
1606
|
+
displayOffset += lineDisplayWidth;
|
|
1607
|
+
}
|
|
1608
|
+
return result;
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Find where a formatted line starts in the normalized original text.
|
|
1612
|
+
*
|
|
1613
|
+
* Scans forward from the given offset, matching the line content
|
|
1614
|
+
* character by character. Skips newlines and whitespace that were
|
|
1615
|
+
* consumed by wrapping between lines.
|
|
1616
|
+
*/
|
|
1617
|
+
function findLineStart(normalized, plainLine, fromOffset) {
|
|
1618
|
+
if (plainLine.length === 0) {
|
|
1619
|
+
let pos = fromOffset;
|
|
1620
|
+
while (pos < normalized.length && normalized[pos] === "\n") pos++;
|
|
1621
|
+
return pos;
|
|
1622
|
+
}
|
|
1623
|
+
if (normalized.startsWith(plainLine, fromOffset)) return fromOffset;
|
|
1624
|
+
const ellipsisIdx = plainLine.indexOf("…");
|
|
1625
|
+
const truncatedPrefix = ellipsisIdx > 0 ? plainLine.slice(0, ellipsisIdx) : null;
|
|
1626
|
+
if (truncatedPrefix && normalized.startsWith(truncatedPrefix, fromOffset)) return fromOffset;
|
|
1627
|
+
let pos = fromOffset;
|
|
1628
|
+
while (pos < normalized.length) {
|
|
1629
|
+
const ch = normalized[pos];
|
|
1630
|
+
if (ch === "\n" || ch === " ") {
|
|
1631
|
+
pos++;
|
|
1632
|
+
continue;
|
|
1633
|
+
}
|
|
1634
|
+
if (normalized.startsWith(plainLine, pos)) return pos;
|
|
1635
|
+
if (truncatedPrefix && normalized.startsWith(truncatedPrefix, pos)) return pos;
|
|
1636
|
+
pos++;
|
|
1637
|
+
}
|
|
1638
|
+
return fromOffset;
|
|
1639
|
+
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Format text into lines based on wrap mode.
|
|
1642
|
+
*
|
|
1643
|
+
* @param trim - When true, trims trailing spaces on broken lines and skips leading
|
|
1644
|
+
* spaces on continuation lines. When false (e.g., text has backgroundColor),
|
|
1645
|
+
* preserves trailing spaces so background color covers them. Defaults to true.
|
|
1646
|
+
*/
|
|
1647
|
+
function formatTextLines(text, width, wrap, ctx, trim = true) {
|
|
1648
|
+
if (width <= 0) return [];
|
|
1649
|
+
const normalizedText = text.replace(/\t/g, " ");
|
|
1650
|
+
const lines = normalizedText.split("\n");
|
|
1651
|
+
if (wrap === "clip") {
|
|
1652
|
+
const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth;
|
|
1653
|
+
return lines.map((line) => {
|
|
1654
|
+
if (getTextWidth(line, ctx) <= width) return line;
|
|
1655
|
+
return sliceFn(line, width);
|
|
1656
|
+
});
|
|
1657
|
+
}
|
|
1658
|
+
if (wrap === "hard") {
|
|
1659
|
+
const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth;
|
|
1660
|
+
const out = [];
|
|
1661
|
+
for (const line of lines) {
|
|
1662
|
+
if (line === "") {
|
|
1663
|
+
out.push("");
|
|
1664
|
+
continue;
|
|
1665
|
+
}
|
|
1666
|
+
let remaining = line;
|
|
1667
|
+
while (getTextWidth(remaining, ctx) > width) {
|
|
1668
|
+
const head = sliceFn(remaining, width);
|
|
1669
|
+
if (head.length === 0) break;
|
|
1670
|
+
out.push(head);
|
|
1671
|
+
remaining = remaining.slice(head.length);
|
|
1672
|
+
}
|
|
1673
|
+
out.push(remaining);
|
|
1674
|
+
}
|
|
1675
|
+
return out;
|
|
1676
|
+
}
|
|
1677
|
+
if (wrap === false || wrap === "truncate-end" || wrap === "truncate") return lines.map((line) => truncateText(line, width, "end", ctx));
|
|
1678
|
+
if (wrap === "truncate-start") return lines.map((line) => truncateText(line, width, "start", ctx));
|
|
1679
|
+
if (wrap === "truncate-middle") return lines.map((line) => truncateText(line, width, "middle", ctx));
|
|
1680
|
+
if (wrap === "even") return optimalWrap(normalizedText, buildTextAnalysis(normalizedText, ctx?.measurer?.graphemeWidth?.bind(ctx.measurer) ?? graphemeWidth), width);
|
|
1681
|
+
if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, trim);
|
|
1682
|
+
return wrapText(normalizedText, width, true, trim);
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Truncate text to fit within width.
|
|
1686
|
+
*/
|
|
1687
|
+
function truncateText(text, width, mode, ctx) {
|
|
1688
|
+
if (getTextWidth(text, ctx) <= width) return text;
|
|
1689
|
+
const ellipsis = "…";
|
|
1690
|
+
const availableWidth = width - 1;
|
|
1691
|
+
if (availableWidth <= 0) return width > 0 ? ellipsis : "";
|
|
1692
|
+
const sliceFn = ctx ? ctx.measurer.sliceByWidth : sliceByWidth;
|
|
1693
|
+
const sliceEndFn = ctx ? ctx.measurer.sliceByWidthFromEnd : sliceByWidthFromEnd;
|
|
1694
|
+
if (mode === "end") return sliceFn(text, availableWidth) + ellipsis;
|
|
1695
|
+
if (mode === "start") return ellipsis + sliceEndFn(text, availableWidth);
|
|
1696
|
+
const halfWidth = Math.floor(availableWidth / 2);
|
|
1697
|
+
const startPart = sliceFn(text, halfWidth);
|
|
1698
|
+
const endPart = sliceEndFn(text, availableWidth - halfWidth);
|
|
1699
|
+
return startPart + ellipsis + endPart;
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Render a single line of text to the buffer.
|
|
1703
|
+
*
|
|
1704
|
+
* @param maxCol - Right edge of the text node's layout area. Wide characters
|
|
1705
|
+
* whose continuation cell would exceed this boundary are replaced with a
|
|
1706
|
+
* space, matching terminal behavior for wide chars at the screen edge.
|
|
1707
|
+
* Without this, continuation cells overflow into adjacent containers and
|
|
1708
|
+
* become stale during incremental rendering (the owning container's dirty
|
|
1709
|
+
* tracking doesn't cover cells outside its layout bounds).
|
|
1710
|
+
*/
|
|
1711
|
+
function renderTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx) {
|
|
1712
|
+
if (hasAnsi(text)) {
|
|
1713
|
+
renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx);
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx);
|
|
1717
|
+
}
|
|
1718
|
+
/**
|
|
1719
|
+
* Like renderTextLine but returns the column position after the last rendered character.
|
|
1720
|
+
* Used by renderText to know where to clear remaining cells.
|
|
1721
|
+
*/
|
|
1722
|
+
function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol) {
|
|
1723
|
+
if (hasAnsi(text)) return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol);
|
|
1724
|
+
return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, minCol);
|
|
1725
|
+
}
|
|
1726
|
+
/**
|
|
1727
|
+
* Render graphemes to buffer cells with proper Unicode handling.
|
|
1728
|
+
* Shared by renderTextLine (plain text) and renderAnsiTextLine (per-segment).
|
|
1729
|
+
*
|
|
1730
|
+
* @param maxCol - Right edge of the text node's layout area (exclusive).
|
|
1731
|
+
* Wide characters whose continuation cell would reach or exceed this
|
|
1732
|
+
* boundary are replaced with a space character. This matches terminal
|
|
1733
|
+
* behavior for wide chars at the right edge of a container and prevents
|
|
1734
|
+
* continuation cells from overflowing into adjacent containers, where
|
|
1735
|
+
* they become stale during incremental rendering.
|
|
1736
|
+
* @param minCol - Left edge of the visible region (inclusive). Graphemes
|
|
1737
|
+
* whose end position is at or before minCol are skipped (col still advances).
|
|
1738
|
+
* Used to clip text that overflows the LEFT edge of an overflow:hidden
|
|
1739
|
+
* container with a border (so the border isn't overwritten).
|
|
1740
|
+
*
|
|
1741
|
+
* Returns the column position after the last rendered grapheme.
|
|
1742
|
+
*/
|
|
1743
|
+
function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inheritedBg, ctx, minCol) {
|
|
1744
|
+
let col = startCol;
|
|
1745
|
+
const rightEdge = maxCol !== void 0 ? Math.min(maxCol, buffer.width) : buffer.width;
|
|
1746
|
+
const leftEdge = minCol !== void 0 ? Math.max(minCol, 0) : 0;
|
|
1747
|
+
const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth;
|
|
1748
|
+
for (const grapheme of graphemes) {
|
|
1749
|
+
if (col >= rightEdge) break;
|
|
1750
|
+
const width = gWidthFn(grapheme);
|
|
1751
|
+
if (width === 0) continue;
|
|
1752
|
+
if (col + width <= leftEdge) {
|
|
1753
|
+
col += width;
|
|
1754
|
+
continue;
|
|
1755
|
+
}
|
|
1756
|
+
if (col < leftEdge) {
|
|
1757
|
+
col = leftEdge;
|
|
1758
|
+
continue;
|
|
1759
|
+
}
|
|
1760
|
+
const existingBg = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : buffer.getCellBg(col, y);
|
|
1761
|
+
if (width === 2 && col + 1 >= rightEdge) {
|
|
1762
|
+
buffer.setCell(col, y, {
|
|
1763
|
+
char: " ",
|
|
1764
|
+
fg: style.fg,
|
|
1765
|
+
bg: existingBg,
|
|
1766
|
+
underlineColor: style.underlineColor ?? null,
|
|
1767
|
+
attrs: style.attrs,
|
|
1768
|
+
wide: false,
|
|
1769
|
+
continuation: false,
|
|
1770
|
+
hyperlink: style.hyperlink
|
|
1771
|
+
});
|
|
1772
|
+
col += 1;
|
|
1773
|
+
continue;
|
|
1774
|
+
}
|
|
1775
|
+
const outputChar = width === 2 ? ensureEmojiPresentation(grapheme) : grapheme;
|
|
1776
|
+
buffer.setCell(col, y, {
|
|
1777
|
+
char: outputChar,
|
|
1778
|
+
fg: style.fg,
|
|
1779
|
+
bg: existingBg,
|
|
1780
|
+
underlineColor: style.underlineColor ?? null,
|
|
1781
|
+
attrs: style.attrs,
|
|
1782
|
+
wide: width === 2,
|
|
1783
|
+
continuation: false,
|
|
1784
|
+
hyperlink: style.hyperlink
|
|
1785
|
+
});
|
|
1786
|
+
if (width === 2 && col + 1 < buffer.width) {
|
|
1787
|
+
const existingBg2 = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : buffer.getCellBg(col + 1, y);
|
|
1788
|
+
buffer.setCell(col + 1, y, {
|
|
1789
|
+
char: "",
|
|
1790
|
+
fg: style.fg,
|
|
1791
|
+
bg: existingBg2,
|
|
1792
|
+
underlineColor: style.underlineColor ?? null,
|
|
1793
|
+
attrs: style.attrs,
|
|
1794
|
+
wide: false,
|
|
1795
|
+
continuation: true,
|
|
1796
|
+
hyperlink: style.hyperlink
|
|
1797
|
+
});
|
|
1798
|
+
col += 2;
|
|
1799
|
+
} else col += width;
|
|
1800
|
+
}
|
|
1801
|
+
return col;
|
|
1802
|
+
}
|
|
1803
|
+
/**
|
|
1804
|
+
* Render text line with ANSI escape sequences.
|
|
1805
|
+
* Parses ANSI codes and applies styles to individual segments.
|
|
1806
|
+
*/
|
|
1807
|
+
function renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx) {
|
|
1808
|
+
renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx);
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Like renderAnsiTextLine but returns the column position after the last rendered character.
|
|
1812
|
+
*/
|
|
1813
|
+
function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol) {
|
|
1814
|
+
const segments = parseAnsiText(text);
|
|
1815
|
+
let col = x;
|
|
1816
|
+
for (const segment of segments) {
|
|
1817
|
+
const style = mergeAnsiStyle(baseStyle, segment);
|
|
1818
|
+
const effectiveBgConflictMode = ctx?.bgConflictMode ?? getBgConflictMode();
|
|
1819
|
+
if (effectiveBgConflictMode !== "ignore" && !segment.bgOverride && segment.bg !== void 0 && segment.bg !== null) {
|
|
1820
|
+
const existingBufBg = col < buffer.width ? buffer.getCellBg(col, y) : null;
|
|
1821
|
+
if (baseStyle.bg !== null || existingBufBg !== null) {
|
|
1822
|
+
const preview = segment.text.slice(0, 30);
|
|
1823
|
+
const chalkBg = formatBgConflictColor(segment.bg);
|
|
1824
|
+
const silveryBg = baseStyle.bg !== null ? `Text.bg=${formatBgConflictColor(baseStyle.bg)}` : `bufferBg=${formatBgConflictColor(existingBufBg)}`;
|
|
1825
|
+
const textPreview = text.length > 80 ? text.slice(0, 80) + "…" : text;
|
|
1826
|
+
const msg = `[silvery] Background conflict at (${col},${y}): chalk bg=${chalkBg} on silvery ${silveryBg}. Text: "${preview}${segment.text.length > 30 ? "…" : ""}". Raw ANSI (first 80): ${JSON.stringify(textPreview)}. Chalk bg will override only text characters, causing visual gaps in padding. Use ansi.bgOverride() to suppress if intentional.`;
|
|
1827
|
+
if (effectiveBgConflictMode === "throw") throw new Error(msg);
|
|
1828
|
+
const effectiveWarnedBgConflicts = ctx?.warnedBgConflicts ?? warnedBgConflicts;
|
|
1829
|
+
const key = `${JSON.stringify(existingBufBg)}-${segment.bg}-${preview}`;
|
|
1830
|
+
if (!effectiveWarnedBgConflicts.has(key)) {
|
|
1831
|
+
effectiveWarnedBgConflicts.add(key);
|
|
1832
|
+
log$2.warn?.(msg);
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx, minCol);
|
|
1837
|
+
}
|
|
1838
|
+
return col;
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* Merge two styles using category-based semantics.
|
|
1842
|
+
*
|
|
1843
|
+
* Categories and their merge behavior:
|
|
1844
|
+
* - Container (bg): overlay replaces base
|
|
1845
|
+
* - Text (fg): overlay replaces base
|
|
1846
|
+
* - Decorations (underline*, strikethrough): OR merge if preserveDecorations=true
|
|
1847
|
+
* - Emphasis (bold, dim, italic): OR merge if preserveEmphasis=true
|
|
1848
|
+
* - Transform (inverse, hidden, blink): overlay only, not inherited
|
|
1849
|
+
*
|
|
1850
|
+
* @param base - The base style (from parent/container)
|
|
1851
|
+
* @param overlay - The overlay style (from child/content)
|
|
1852
|
+
* @param options - Merge behavior options
|
|
1853
|
+
*/
|
|
1854
|
+
function mergeStyles(base, overlay, options = {}) {
|
|
1855
|
+
const { preserveDecorations = true, preserveEmphasis = true } = options;
|
|
1856
|
+
const baseAttrs = base.attrs ?? {};
|
|
1857
|
+
const overlayAttrs = overlay.attrs ?? {};
|
|
1858
|
+
const attrs = {};
|
|
1859
|
+
if (preserveDecorations) {
|
|
1860
|
+
const hasBaseUnderline = baseAttrs.underline || baseAttrs.underlineStyle;
|
|
1861
|
+
const hasOverlayUnderline = overlayAttrs.underline || overlayAttrs.underlineStyle;
|
|
1862
|
+
if (hasBaseUnderline || hasOverlayUnderline) {
|
|
1863
|
+
attrs.underline = true;
|
|
1864
|
+
attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle ?? "single";
|
|
1865
|
+
}
|
|
1866
|
+
attrs.strikethrough = overlayAttrs.strikethrough || baseAttrs.strikethrough;
|
|
1867
|
+
} else {
|
|
1868
|
+
attrs.underline = overlayAttrs.underline ?? baseAttrs.underline;
|
|
1869
|
+
attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle;
|
|
1870
|
+
attrs.strikethrough = overlayAttrs.strikethrough ?? baseAttrs.strikethrough;
|
|
1871
|
+
}
|
|
1872
|
+
if (preserveEmphasis) {
|
|
1873
|
+
attrs.bold = overlayAttrs.bold || baseAttrs.bold;
|
|
1874
|
+
attrs.dim = overlayAttrs.dim || baseAttrs.dim;
|
|
1875
|
+
attrs.italic = overlayAttrs.italic || baseAttrs.italic;
|
|
1876
|
+
} else {
|
|
1877
|
+
attrs.bold = overlayAttrs.bold ?? baseAttrs.bold;
|
|
1878
|
+
attrs.dim = overlayAttrs.dim ?? baseAttrs.dim;
|
|
1879
|
+
attrs.italic = overlayAttrs.italic ?? baseAttrs.italic;
|
|
1880
|
+
}
|
|
1881
|
+
attrs.inverse = overlayAttrs.inverse;
|
|
1882
|
+
attrs.hidden = overlayAttrs.hidden;
|
|
1883
|
+
attrs.blink = overlayAttrs.blink;
|
|
1884
|
+
return {
|
|
1885
|
+
fg: overlay.fg ?? base.fg,
|
|
1886
|
+
bg: overlay.bg ?? base.bg,
|
|
1887
|
+
underlineColor: overlay.underlineColor ?? base.underlineColor,
|
|
1888
|
+
attrs
|
|
1889
|
+
};
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Merge ANSI segment style with base style.
|
|
1893
|
+
* Uses category-based merging to preserve decorations and emphasis.
|
|
1894
|
+
*/
|
|
1895
|
+
function mergeAnsiStyle(base, segment, options = {}) {
|
|
1896
|
+
const { preserveDecorations = true, preserveEmphasis = true } = options;
|
|
1897
|
+
let fg = base.fg;
|
|
1898
|
+
let bg = base.bg;
|
|
1899
|
+
let underlineColor = base.underlineColor ?? null;
|
|
1900
|
+
if (segment.fg !== void 0 && segment.fg !== null) fg = ansiColorToColor(segment.fg);
|
|
1901
|
+
if (segment.bg !== void 0 && segment.bg !== null) bg = ansiColorToColor(segment.bg);
|
|
1902
|
+
if (segment.underlineColor !== void 0 && segment.underlineColor !== null) underlineColor = ansiColorToColor(segment.underlineColor);
|
|
1903
|
+
const overlayAttrs = {};
|
|
1904
|
+
if (segment.bold !== void 0) overlayAttrs.bold = segment.bold;
|
|
1905
|
+
if (segment.dim !== void 0) overlayAttrs.dim = segment.dim;
|
|
1906
|
+
if (segment.italic !== void 0) overlayAttrs.italic = segment.italic;
|
|
1907
|
+
if (segment.underline !== void 0) overlayAttrs.underline = segment.underline;
|
|
1908
|
+
if (segment.underlineStyle !== void 0) overlayAttrs.underlineStyle = segment.underlineStyle;
|
|
1909
|
+
if (segment.inverse !== void 0) overlayAttrs.inverse = segment.inverse;
|
|
1910
|
+
const merged = mergeStyles(base, {
|
|
1911
|
+
fg,
|
|
1912
|
+
bg,
|
|
1913
|
+
underlineColor,
|
|
1914
|
+
attrs: overlayAttrs
|
|
1915
|
+
}, {
|
|
1916
|
+
preserveDecorations,
|
|
1917
|
+
preserveEmphasis
|
|
1918
|
+
});
|
|
1919
|
+
if (segment.hyperlink) merged.hyperlink = segment.hyperlink;
|
|
1920
|
+
return merged;
|
|
1921
|
+
}
|
|
1922
|
+
/**
|
|
1923
|
+
* Convert ANSI SGR color code to our Color type.
|
|
1924
|
+
* Color is: number (256-color index) | { r, g, b } (true color) | null
|
|
1925
|
+
*/
|
|
1926
|
+
function ansiColorToColor(code) {
|
|
1927
|
+
if (code >= 16777216) return {
|
|
1928
|
+
r: code >> 16 & 255,
|
|
1929
|
+
g: code >> 8 & 255,
|
|
1930
|
+
b: code & 255
|
|
1931
|
+
};
|
|
1932
|
+
if (code < 30 || code >= 38 && code < 40 || code >= 48 && code < 90) return code;
|
|
1933
|
+
if (code >= 30 && code <= 37) return code - 30;
|
|
1934
|
+
if (code >= 40 && code <= 47) return code - 40;
|
|
1935
|
+
if (code >= 90 && code <= 97) return code - 90 + 8;
|
|
1936
|
+
if (code >= 100 && code <= 107) return code - 100 + 8;
|
|
1937
|
+
return null;
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Render a Text node.
|
|
1941
|
+
*
|
|
1942
|
+
* Background colors from nested Text elements are handled at the buffer level
|
|
1943
|
+
* (not via ANSI codes) to prevent bg bleed across wrapped text lines.
|
|
1944
|
+
* See km-silvery.bg-bleed for details.
|
|
1945
|
+
*/
|
|
1946
|
+
function renderText(node, buffer, layout, props, nodeState, inheritedBg, inheritedFg, ctx) {
|
|
1947
|
+
const { scrollOffset, clipBounds } = nodeState;
|
|
1948
|
+
const { x, width, height } = layout;
|
|
1949
|
+
let { y } = layout;
|
|
1950
|
+
y -= scrollOffset;
|
|
1951
|
+
if (props.backgroundColor === "") inheritedBg = null;
|
|
1952
|
+
if (clipBounds) {
|
|
1953
|
+
if (y + height <= clipBounds.top || y >= clipBounds.bottom) return;
|
|
1954
|
+
if (clipBounds.left !== void 0 && clipBounds.right !== void 0) {
|
|
1955
|
+
if (x + width <= clipBounds.left || x >= clipBounds.right) return;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
let maxDisplayWidth;
|
|
1959
|
+
if ((props.wrap === false || props.wrap === "truncate-end" || props.wrap === "truncate" || props.wrap === "clip") && width > 0) {
|
|
1960
|
+
const cachedPlain = getCachedPlainText(node);
|
|
1961
|
+
let lineCount;
|
|
1962
|
+
if (cachedPlain) lineCount = cachedPlain.lineCount;
|
|
1963
|
+
else {
|
|
1964
|
+
const plainText = collectPlainText(node);
|
|
1965
|
+
lineCount = (plainText.match(/\n/g)?.length ?? 0) + 1;
|
|
1966
|
+
setCachedPlainText(node, plainText, lineCount);
|
|
1967
|
+
}
|
|
1968
|
+
maxDisplayWidth = (width + 1) * lineCount;
|
|
1969
|
+
}
|
|
1970
|
+
let text;
|
|
1971
|
+
let bgSegments;
|
|
1972
|
+
let childSpans;
|
|
1973
|
+
const cachedCollected = getCachedCollectedText(node, maxDisplayWidth);
|
|
1974
|
+
if (cachedCollected) {
|
|
1975
|
+
text = cachedCollected.text;
|
|
1976
|
+
bgSegments = cachedCollected.bgSegments;
|
|
1977
|
+
childSpans = cachedCollected.childSpans;
|
|
1978
|
+
} else {
|
|
1979
|
+
const collected = collectTextWithBg(node, {}, 0, maxDisplayWidth, ctx);
|
|
1980
|
+
text = collected.text;
|
|
1981
|
+
bgSegments = collected.bgSegments;
|
|
1982
|
+
childSpans = collected.childSpans;
|
|
1983
|
+
setCachedCollectedText(node, collected, maxDisplayWidth);
|
|
1984
|
+
}
|
|
1985
|
+
const style = getTextStyle(props);
|
|
1986
|
+
if (style.fg === null && inheritedFg !== void 0) style.fg = inheritedFg;
|
|
1987
|
+
const trim = !(style.bg !== null || bgSegments.length > 0 || inheritedBg !== void 0 && inheritedBg !== null);
|
|
1988
|
+
const internalTransform = props.internal_transform;
|
|
1989
|
+
let lines;
|
|
1990
|
+
let lineOffsets;
|
|
1991
|
+
const cachedFmt = !internalTransform ? getCachedFormat(node, width, props.wrap, trim) : null;
|
|
1992
|
+
if (cachedFmt) {
|
|
1993
|
+
lines = cachedFmt.lines;
|
|
1994
|
+
lineOffsets = cachedFmt.hasLineOffsets ? cachedFmt.lineOffsets : [];
|
|
1995
|
+
} else {
|
|
1996
|
+
lines = formatTextLines(text, width, props.wrap, ctx, trim);
|
|
1997
|
+
if (internalTransform) lines = lines.map((line, index) => internalTransform(line, index));
|
|
1998
|
+
const needLineOffsets = bgSegments.length > 0 || childSpans.length > 0;
|
|
1999
|
+
lineOffsets = needLineOffsets ? mapLinesToCharOffsets(text, lines, ctx) : [];
|
|
2000
|
+
if (!internalTransform) setCachedFormat(node, width, props.wrap, trim, lines, lineOffsets, needLineOffsets);
|
|
2001
|
+
}
|
|
2002
|
+
for (let lineIdx = 0; lineIdx < lines.length && lineIdx < height; lineIdx++) {
|
|
2003
|
+
const lineY = y + lineIdx;
|
|
2004
|
+
if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) continue;
|
|
2005
|
+
const line = lines[lineIdx];
|
|
2006
|
+
const layoutRight = internalTransform ? buffer.width : x + width;
|
|
2007
|
+
const maxCol = clipBounds && "right" in clipBounds && clipBounds.right !== void 0 ? Math.min(layoutRight, clipBounds.right) : layoutRight;
|
|
2008
|
+
const minCol = clipBounds && "left" in clipBounds && clipBounds.left !== void 0 ? clipBounds.left : void 0;
|
|
2009
|
+
const endCol = renderTextLineReturn(buffer, x, lineY, line, style, maxCol, inheritedBg, ctx, minCol);
|
|
2010
|
+
const clearStart = minCol !== void 0 ? Math.max(endCol, minCol) : endCol;
|
|
2011
|
+
if (clearStart < maxCol) {
|
|
2012
|
+
const clearBg = inheritedBg ?? null;
|
|
2013
|
+
for (let cx = clearStart; cx < maxCol && cx < buffer.width; cx++) buffer.setCell(cx, lineY, {
|
|
2014
|
+
char: " ",
|
|
2015
|
+
fg: style.fg,
|
|
2016
|
+
bg: clearBg,
|
|
2017
|
+
underlineColor: null,
|
|
2018
|
+
attrs: {
|
|
2019
|
+
bold: false,
|
|
2020
|
+
dim: false,
|
|
2021
|
+
italic: false,
|
|
2022
|
+
underline: false,
|
|
2023
|
+
inverse: false,
|
|
2024
|
+
strikethrough: false,
|
|
2025
|
+
blink: false,
|
|
2026
|
+
hidden: false
|
|
2027
|
+
},
|
|
2028
|
+
wide: false,
|
|
2029
|
+
continuation: false
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
if (bgSegments.length > 0 && lineIdx < lineOffsets.length) {
|
|
2033
|
+
const { start, end } = lineOffsets[lineIdx];
|
|
2034
|
+
applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx);
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
if (childSpans.length > 0 && lineOffsets.length > 0) computeInlineRects(childSpans, lineOffsets, x, y, lines.length, height);
|
|
2038
|
+
}
|
|
2039
|
+
/**
|
|
2040
|
+
* Compute inlineRects for virtual text children based on their display-width spans
|
|
2041
|
+
* and the formatted line offsets. For wrapped text, a child may span multiple lines,
|
|
2042
|
+
* producing one rect per line fragment.
|
|
2043
|
+
*
|
|
2044
|
+
* @param childSpans - Virtual text children with their display-width ranges
|
|
2045
|
+
* @param lineOffsets - Display-width offset ranges for each formatted line
|
|
2046
|
+
* @param parentX - Screen X of the parent Text node
|
|
2047
|
+
* @param parentY - Screen Y of the parent Text node (after scroll offset)
|
|
2048
|
+
* @param lineCount - Number of formatted lines
|
|
2049
|
+
* @param maxHeight - Maximum height (layout height) of the parent Text node
|
|
2050
|
+
*/
|
|
2051
|
+
function computeInlineRects(childSpans, lineOffsets, parentX, parentY, lineCount, maxHeight) {
|
|
2052
|
+
for (const span of childSpans) {
|
|
2053
|
+
const rects = [];
|
|
2054
|
+
for (let lineIdx = 0; lineIdx < lineCount && lineIdx < maxHeight; lineIdx++) {
|
|
2055
|
+
const lineOffset = lineOffsets[lineIdx];
|
|
2056
|
+
if (!lineOffset) continue;
|
|
2057
|
+
const overlapStart = Math.max(span.start, lineOffset.start);
|
|
2058
|
+
const overlapEnd = Math.min(span.end, lineOffset.end);
|
|
2059
|
+
if (overlapStart >= overlapEnd) continue;
|
|
2060
|
+
const rectX = parentX + (overlapStart - lineOffset.start);
|
|
2061
|
+
const rectY = parentY + lineIdx;
|
|
2062
|
+
const rectWidth = overlapEnd - overlapStart;
|
|
2063
|
+
rects.push({
|
|
2064
|
+
x: rectX,
|
|
2065
|
+
y: rectY,
|
|
2066
|
+
width: rectWidth,
|
|
2067
|
+
height: 1
|
|
2068
|
+
});
|
|
2069
|
+
}
|
|
2070
|
+
span.node.inlineRects = rects.length > 0 ? rects : null;
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
//#endregion
|
|
2074
|
+
//#region packages/ag-term/src/pipeline/render-box.ts
|
|
2075
|
+
/**
|
|
2076
|
+
* Get the effective background color string for a Box.
|
|
2077
|
+
* Returns explicit backgroundColor if set, otherwise theme.bg if theme is set.
|
|
2078
|
+
* Used by both renderBox (paint fill) and render-phase (cascade logic).
|
|
2079
|
+
*/
|
|
2080
|
+
function getEffectiveBg(props) {
|
|
2081
|
+
if (props.backgroundColor) return props.backgroundColor;
|
|
2082
|
+
if (props.theme) return props.theme.bg;
|
|
2083
|
+
}
|
|
2084
|
+
/**
|
|
2085
|
+
* Render a Box node.
|
|
2086
|
+
*/
|
|
2087
|
+
function renderBox(_node, buffer, layout, props, nodeState, skipBgFill = false, inheritedBg, bgOnlyChange = false) {
|
|
2088
|
+
const { scrollOffset, clipBounds } = nodeState;
|
|
2089
|
+
const { x, width, height } = layout;
|
|
2090
|
+
const y = layout.y - scrollOffset;
|
|
2091
|
+
if (clipBounds) {
|
|
2092
|
+
if (y + height <= clipBounds.top || y >= clipBounds.bottom) return;
|
|
2093
|
+
if (clipBounds.left !== void 0 && clipBounds.right !== void 0) {
|
|
2094
|
+
if (x + width <= clipBounds.left || x >= clipBounds.right) return;
|
|
2095
|
+
}
|
|
2096
|
+
}
|
|
2097
|
+
const effectiveBgStr = getEffectiveBg(props);
|
|
2098
|
+
if (effectiveBgStr && !skipBgFill) {
|
|
2099
|
+
const bg = parseColor(effectiveBgStr);
|
|
2100
|
+
if (clipBounds) {
|
|
2101
|
+
const clippedY = Math.max(y, clipBounds.top);
|
|
2102
|
+
const clippedHeight = Math.min(y + height, clipBounds.bottom) - clippedY;
|
|
2103
|
+
let clippedX = x;
|
|
2104
|
+
let clippedWidth = width;
|
|
2105
|
+
if (clipBounds.left !== void 0 && clipBounds.right !== void 0) {
|
|
2106
|
+
clippedX = Math.max(x, clipBounds.left);
|
|
2107
|
+
clippedWidth = Math.min(x + width, clipBounds.right) - clippedX;
|
|
2108
|
+
}
|
|
2109
|
+
if (clippedHeight > 0 && clippedWidth > 0) if (bgOnlyChange) buffer.fillBg(clippedX, clippedY, clippedWidth, clippedHeight, bg);
|
|
2110
|
+
else buffer.fill(clippedX, clippedY, clippedWidth, clippedHeight, { bg });
|
|
2111
|
+
} else if (bgOnlyChange) buffer.fillBg(x, y, width, height, bg);
|
|
2112
|
+
else buffer.fill(x, y, width, height, { bg });
|
|
2113
|
+
}
|
|
2114
|
+
if (props.borderStyle) renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg);
|
|
2115
|
+
}
|
|
2116
|
+
/**
|
|
2117
|
+
* Render a border around a box.
|
|
2118
|
+
*/
|
|
2119
|
+
function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg) {
|
|
2120
|
+
const chars = getBorderChars(props.borderStyle ?? "single");
|
|
2121
|
+
const color = props.borderColor ? parseColor(props.borderColor) : null;
|
|
2122
|
+
const baseBg = props.backgroundColor ? parseColor(props.backgroundColor) : inheritedBg ?? null;
|
|
2123
|
+
const borderBgStr = props.borderBackgroundColor;
|
|
2124
|
+
const borderBgBase = borderBgStr ? parseColor(borderBgStr) : baseBg;
|
|
2125
|
+
const topBorderBgStr = props.borderTopBackgroundColor;
|
|
2126
|
+
const bottomBorderBgStr = props.borderBottomBackgroundColor;
|
|
2127
|
+
const leftBorderBgStr = props.borderLeftBackgroundColor;
|
|
2128
|
+
const rightBorderBgStr = props.borderRightBackgroundColor;
|
|
2129
|
+
const topBg = topBorderBgStr ? parseColor(topBorderBgStr) : borderBgBase;
|
|
2130
|
+
const bottomBg = bottomBorderBgStr ? parseColor(bottomBorderBgStr) : borderBgBase;
|
|
2131
|
+
const leftBg = leftBorderBgStr ? parseColor(leftBorderBgStr) : borderBgBase;
|
|
2132
|
+
const rightBg = rightBorderBgStr ? parseColor(rightBorderBgStr) : borderBgBase;
|
|
2133
|
+
const showTop = props.borderTop !== false;
|
|
2134
|
+
const showBottom = props.borderBottom !== false;
|
|
2135
|
+
const showLeft = props.borderLeft !== false;
|
|
2136
|
+
const showRight = props.borderRight !== false;
|
|
2137
|
+
const isRowVisible = (row) => {
|
|
2138
|
+
if (!clipBounds) return row >= 0 && row < buffer.height;
|
|
2139
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height;
|
|
2140
|
+
};
|
|
2141
|
+
const isColVisible = (col) => {
|
|
2142
|
+
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < buffer.width;
|
|
2143
|
+
return col >= clipBounds.left && col < clipBounds.right && col < buffer.width;
|
|
2144
|
+
};
|
|
2145
|
+
if (showTop && isRowVisible(y)) {
|
|
2146
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, y, {
|
|
2147
|
+
char: chars.topLeft,
|
|
2148
|
+
fg: color,
|
|
2149
|
+
bg: topBg
|
|
2150
|
+
});
|
|
2151
|
+
const hStart = showLeft ? x + 1 : x;
|
|
2152
|
+
const hEnd = showRight ? x + width - 1 : x + width;
|
|
2153
|
+
for (let col = hStart; col < hEnd && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, y, {
|
|
2154
|
+
char: chars.horizontal,
|
|
2155
|
+
fg: color,
|
|
2156
|
+
bg: topBg
|
|
2157
|
+
});
|
|
2158
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, y, {
|
|
2159
|
+
char: chars.topRight,
|
|
2160
|
+
fg: color,
|
|
2161
|
+
bg: topBg
|
|
2162
|
+
});
|
|
2163
|
+
}
|
|
2164
|
+
const rightVertical = chars.rightVertical ?? chars.vertical;
|
|
2165
|
+
const sideStart = showTop ? y + 1 : y;
|
|
2166
|
+
const sideEnd = showBottom ? y + height - 1 : y + height;
|
|
2167
|
+
for (let row = sideStart; row < sideEnd; row++) {
|
|
2168
|
+
if (!isRowVisible(row)) continue;
|
|
2169
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, row, {
|
|
2170
|
+
char: chars.vertical,
|
|
2171
|
+
fg: color,
|
|
2172
|
+
bg: leftBg
|
|
2173
|
+
});
|
|
2174
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, row, {
|
|
2175
|
+
char: rightVertical,
|
|
2176
|
+
fg: color,
|
|
2177
|
+
bg: rightBg
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
const bottomHorizontal = chars.bottomHorizontal ?? chars.horizontal;
|
|
2181
|
+
const bottomY = y + height - 1;
|
|
2182
|
+
if (showBottom && isRowVisible(bottomY)) {
|
|
2183
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, bottomY, {
|
|
2184
|
+
char: chars.bottomLeft,
|
|
2185
|
+
fg: color,
|
|
2186
|
+
bg: bottomBg
|
|
2187
|
+
});
|
|
2188
|
+
const bStart = showLeft ? x + 1 : x;
|
|
2189
|
+
const bEnd = showRight ? x + width - 1 : x + width;
|
|
2190
|
+
for (let col = bStart; col < bEnd && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, bottomY, {
|
|
2191
|
+
char: bottomHorizontal,
|
|
2192
|
+
fg: color,
|
|
2193
|
+
bg: bottomBg
|
|
2194
|
+
});
|
|
2195
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, bottomY, {
|
|
2196
|
+
char: chars.bottomRight,
|
|
2197
|
+
fg: color,
|
|
2198
|
+
bg: bottomBg
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
/**
|
|
2203
|
+
* Render an outline around a box.
|
|
2204
|
+
*
|
|
2205
|
+
* Unlike borders, outlines do NOT affect layout dimensions. They draw border
|
|
2206
|
+
* characters that OVERLAP the content area at the node's screen rect edges.
|
|
2207
|
+
* This is the CSS `outline` equivalent for terminal UI.
|
|
2208
|
+
*/
|
|
2209
|
+
function renderOutline(buffer, x, y, width, height, props, clipBounds, inheritedBg) {
|
|
2210
|
+
const chars = getBorderChars(props.outlineStyle ?? "single");
|
|
2211
|
+
const color = props.outlineColor ? parseColor(props.outlineColor) : null;
|
|
2212
|
+
const bg = props.backgroundColor ? parseColor(props.backgroundColor) : inheritedBg ?? null;
|
|
2213
|
+
const attrs = props.outlineDimColor ? { dim: true } : {};
|
|
2214
|
+
const isRowVisible = (row) => {
|
|
2215
|
+
if (!clipBounds) return row >= 0 && row < buffer.height;
|
|
2216
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < buffer.height;
|
|
2217
|
+
};
|
|
2218
|
+
const isColVisible = (col) => {
|
|
2219
|
+
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < buffer.width;
|
|
2220
|
+
return col >= clipBounds.left && col < clipBounds.right && col < buffer.width;
|
|
2221
|
+
};
|
|
2222
|
+
const showTop = props.outlineTop !== false;
|
|
2223
|
+
const showBottom = props.outlineBottom !== false;
|
|
2224
|
+
const showLeft = props.outlineLeft !== false;
|
|
2225
|
+
const showRight = props.outlineRight !== false;
|
|
2226
|
+
if (showTop && isRowVisible(y)) {
|
|
2227
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, y, {
|
|
2228
|
+
char: chars.topLeft,
|
|
2229
|
+
fg: color,
|
|
2230
|
+
bg,
|
|
2231
|
+
attrs
|
|
2232
|
+
});
|
|
2233
|
+
for (let col = x + 1; col < x + width - 1 && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, y, {
|
|
2234
|
+
char: chars.horizontal,
|
|
2235
|
+
fg: color,
|
|
2236
|
+
bg,
|
|
2237
|
+
attrs
|
|
2238
|
+
});
|
|
2239
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, y, {
|
|
2240
|
+
char: chars.topRight,
|
|
2241
|
+
fg: color,
|
|
2242
|
+
bg,
|
|
2243
|
+
attrs
|
|
2244
|
+
});
|
|
2245
|
+
}
|
|
2246
|
+
const outlineRightVertical = chars.rightVertical ?? chars.vertical;
|
|
2247
|
+
const sideStart = showTop ? y + 1 : y;
|
|
2248
|
+
const sideEnd = showBottom ? y + height - 1 : y + height;
|
|
2249
|
+
for (let row = sideStart; row < sideEnd; row++) {
|
|
2250
|
+
if (!isRowVisible(row)) continue;
|
|
2251
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, row, {
|
|
2252
|
+
char: chars.vertical,
|
|
2253
|
+
fg: color,
|
|
2254
|
+
bg,
|
|
2255
|
+
attrs
|
|
2256
|
+
});
|
|
2257
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, row, {
|
|
2258
|
+
char: outlineRightVertical,
|
|
2259
|
+
fg: color,
|
|
2260
|
+
bg,
|
|
2261
|
+
attrs
|
|
2262
|
+
});
|
|
2263
|
+
}
|
|
2264
|
+
const outlineBottomHorizontal = chars.bottomHorizontal ?? chars.horizontal;
|
|
2265
|
+
const bottomY = y + height - 1;
|
|
2266
|
+
if (showBottom && isRowVisible(bottomY)) {
|
|
2267
|
+
if (showLeft && isColVisible(x)) buffer.setCell(x, bottomY, {
|
|
2268
|
+
char: chars.bottomLeft,
|
|
2269
|
+
fg: color,
|
|
2270
|
+
bg,
|
|
2271
|
+
attrs
|
|
2272
|
+
});
|
|
2273
|
+
for (let col = x + 1; col < x + width - 1 && col < buffer.width; col++) if (isColVisible(col)) buffer.setCell(col, bottomY, {
|
|
2274
|
+
char: outlineBottomHorizontal,
|
|
2275
|
+
fg: color,
|
|
2276
|
+
bg,
|
|
2277
|
+
attrs
|
|
2278
|
+
});
|
|
2279
|
+
if (showRight && x + width - 1 < buffer.width && isColVisible(x + width - 1)) buffer.setCell(x + width - 1, bottomY, {
|
|
2280
|
+
char: chars.bottomRight,
|
|
2281
|
+
fg: color,
|
|
2282
|
+
bg,
|
|
2283
|
+
attrs
|
|
2284
|
+
});
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Render scroll indicators showing hidden items above/below viewport.
|
|
2289
|
+
*
|
|
2290
|
+
* Two rendering modes:
|
|
2291
|
+
* 1. Bordered containers: Indicators appear on the border (e.g., "───▲42───")
|
|
2292
|
+
* 2. Borderless containers with overflowIndicator: Indicators appear directly
|
|
2293
|
+
* after the last visible child (not at the viewport bottom)
|
|
2294
|
+
*
|
|
2295
|
+
* Uses ▲N for items hidden above, ▼N for items hidden below.
|
|
2296
|
+
*/
|
|
2297
|
+
function renderScrollIndicators(_node, buffer, layout, props, ss, ctx) {
|
|
2298
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
2299
|
+
top: 0,
|
|
2300
|
+
bottom: 0,
|
|
2301
|
+
left: 0,
|
|
2302
|
+
right: 0
|
|
2303
|
+
};
|
|
2304
|
+
const indicatorStyle = {
|
|
2305
|
+
fg: 15,
|
|
2306
|
+
bg: 8,
|
|
2307
|
+
attrs: {}
|
|
2308
|
+
};
|
|
2309
|
+
const showBorderless = props.overflowIndicator === true;
|
|
2310
|
+
if (ss.hiddenAbove > 0) {
|
|
2311
|
+
const indicator = `\u25b2${ss.hiddenAbove}`;
|
|
2312
|
+
if (border.top > 0) {
|
|
2313
|
+
const contentWidth = layout.width - border.left - border.right;
|
|
2314
|
+
const bar = padCenter(indicator, contentWidth);
|
|
2315
|
+
const x = layout.x + border.left;
|
|
2316
|
+
const y = layout.y;
|
|
2317
|
+
renderTextLine(buffer, x, y, bar, indicatorStyle, x + contentWidth, void 0, ctx);
|
|
2318
|
+
} else if (showBorderless) {
|
|
2319
|
+
const padding = getPadding(props);
|
|
2320
|
+
const contentWidth = layout.width - padding.left - padding.right;
|
|
2321
|
+
const bar = padCenter(indicator, contentWidth);
|
|
2322
|
+
const x = layout.x + padding.left;
|
|
2323
|
+
renderTextLine(buffer, x, layout.y + padding.top, bar, indicatorStyle, x + contentWidth, void 0, ctx);
|
|
2324
|
+
}
|
|
2325
|
+
}
|
|
2326
|
+
if (ss.hiddenBelow > 0) {
|
|
2327
|
+
const indicator = `\u25bc${ss.hiddenBelow}`;
|
|
2328
|
+
if (border.bottom > 0) {
|
|
2329
|
+
const contentWidth = layout.width - border.left - border.right;
|
|
2330
|
+
const bar = padCenter(indicator, contentWidth);
|
|
2331
|
+
const x = layout.x + border.left;
|
|
2332
|
+
renderTextLine(buffer, x, layout.y + layout.height - 1, bar, indicatorStyle, x + contentWidth, void 0, ctx);
|
|
2333
|
+
} else if (showBorderless) {
|
|
2334
|
+
const padding = getPadding(props);
|
|
2335
|
+
const contentWidth = layout.width - padding.left - padding.right;
|
|
2336
|
+
const bar = padCenter(indicator, contentWidth);
|
|
2337
|
+
const x = layout.x + padding.left;
|
|
2338
|
+
renderTextLine(buffer, x, layout.y + layout.height - padding.bottom - 1, bar, indicatorStyle, x + contentWidth, void 0, ctx);
|
|
2339
|
+
}
|
|
2340
|
+
}
|
|
2341
|
+
}
|
|
2342
|
+
/** Center text within a fixed width, padding with spaces on both sides.
|
|
2343
|
+
* Truncates from the right if text exceeds available width. */
|
|
2344
|
+
function padCenter(text, width) {
|
|
2345
|
+
if (width <= 0) return "";
|
|
2346
|
+
if (text.length > width) return text.slice(0, width);
|
|
2347
|
+
if (text.length === width) return text;
|
|
2348
|
+
const leftPad = Math.floor((width - text.length) / 2);
|
|
2349
|
+
const rightPad = width - text.length - leftPad;
|
|
2350
|
+
return " ".repeat(leftPad) + text + " ".repeat(rightPad);
|
|
2351
|
+
}
|
|
2352
|
+
//#endregion
|
|
2353
|
+
//#region packages/ag-term/src/pipeline/cascade-predicates.ts
|
|
2354
|
+
/**
|
|
2355
|
+
* Compute all cascade predicate values from boolean inputs.
|
|
2356
|
+
*
|
|
2357
|
+
* This is a pure function — no side effects, no node dependencies.
|
|
2358
|
+
* The formulas exactly match those in render-phase.ts renderNodeToBuffer.
|
|
2359
|
+
*/
|
|
2360
|
+
function computeCascade(inputs) {
|
|
2361
|
+
const { hasPrevBuffer, contentDirty, stylePropsDirty, layoutChanged, subtreeDirty, childrenDirty, childPositionChanged, ancestorLayoutChanged, ancestorCleared, bgDirty, isTextNode, hasBgColor, absoluteChildMutated, descendantOverflowChanged } = inputs;
|
|
2362
|
+
const canSkipEntireSubtree = hasPrevBuffer && !contentDirty && !stylePropsDirty && !layoutChanged && !subtreeDirty && !childrenDirty && !childPositionChanged && !ancestorLayoutChanged;
|
|
2363
|
+
const contentAreaAffected = contentDirty || layoutChanged || childPositionChanged || childrenDirty || bgDirty || isTextNode && stylePropsDirty || absoluteChildMutated || descendantOverflowChanged;
|
|
2364
|
+
const bgOnlyChange = false;
|
|
2365
|
+
const bgRefillNeeded = hasPrevBuffer && !contentAreaAffected && subtreeDirty && hasBgColor;
|
|
2366
|
+
return {
|
|
2367
|
+
canSkipEntireSubtree,
|
|
2368
|
+
contentAreaAffected,
|
|
2369
|
+
bgRefillNeeded,
|
|
2370
|
+
contentRegionCleared: (hasPrevBuffer || ancestorCleared) && contentAreaAffected && !hasBgColor,
|
|
2371
|
+
skipBgFill: hasPrevBuffer && !ancestorCleared && !contentAreaAffected && !bgRefillNeeded,
|
|
2372
|
+
childrenNeedFreshRender: (hasPrevBuffer || ancestorCleared) && (contentAreaAffected || bgRefillNeeded) && true,
|
|
2373
|
+
bgOnlyChange
|
|
2374
|
+
};
|
|
2375
|
+
}
|
|
2376
|
+
//#endregion
|
|
2377
|
+
//#region ../../node_modules/.bun/alien-signals@3.1.2/node_modules/alien-signals/esm/system.mjs
|
|
2378
|
+
function createReactiveSystem({ update, notify, unwatched }) {
|
|
2379
|
+
return {
|
|
2380
|
+
link,
|
|
2381
|
+
unlink,
|
|
2382
|
+
propagate,
|
|
2383
|
+
checkDirty,
|
|
2384
|
+
shallowPropagate
|
|
2385
|
+
};
|
|
2386
|
+
function link(dep, sub, version) {
|
|
2387
|
+
const prevDep = sub.depsTail;
|
|
2388
|
+
if (prevDep !== void 0 && prevDep.dep === dep) return;
|
|
2389
|
+
const nextDep = prevDep !== void 0 ? prevDep.nextDep : sub.deps;
|
|
2390
|
+
if (nextDep !== void 0 && nextDep.dep === dep) {
|
|
2391
|
+
nextDep.version = version;
|
|
2392
|
+
sub.depsTail = nextDep;
|
|
2393
|
+
return;
|
|
2394
|
+
}
|
|
2395
|
+
const prevSub = dep.subsTail;
|
|
2396
|
+
if (prevSub !== void 0 && prevSub.version === version && prevSub.sub === sub) return;
|
|
2397
|
+
const newLink = sub.depsTail = dep.subsTail = {
|
|
2398
|
+
version,
|
|
2399
|
+
dep,
|
|
2400
|
+
sub,
|
|
2401
|
+
prevDep,
|
|
2402
|
+
nextDep,
|
|
2403
|
+
prevSub,
|
|
2404
|
+
nextSub: void 0
|
|
2405
|
+
};
|
|
2406
|
+
if (nextDep !== void 0) nextDep.prevDep = newLink;
|
|
2407
|
+
if (prevDep !== void 0) prevDep.nextDep = newLink;
|
|
2408
|
+
else sub.deps = newLink;
|
|
2409
|
+
if (prevSub !== void 0) prevSub.nextSub = newLink;
|
|
2410
|
+
else dep.subs = newLink;
|
|
2411
|
+
}
|
|
2412
|
+
function unlink(link, sub = link.sub) {
|
|
2413
|
+
const dep = link.dep;
|
|
2414
|
+
const prevDep = link.prevDep;
|
|
2415
|
+
const nextDep = link.nextDep;
|
|
2416
|
+
const nextSub = link.nextSub;
|
|
2417
|
+
const prevSub = link.prevSub;
|
|
2418
|
+
if (nextDep !== void 0) nextDep.prevDep = prevDep;
|
|
2419
|
+
else sub.depsTail = prevDep;
|
|
2420
|
+
if (prevDep !== void 0) prevDep.nextDep = nextDep;
|
|
2421
|
+
else sub.deps = nextDep;
|
|
2422
|
+
if (nextSub !== void 0) nextSub.prevSub = prevSub;
|
|
2423
|
+
else dep.subsTail = prevSub;
|
|
2424
|
+
if (prevSub !== void 0) prevSub.nextSub = nextSub;
|
|
2425
|
+
else if ((dep.subs = nextSub) === void 0) unwatched(dep);
|
|
2426
|
+
return nextDep;
|
|
2427
|
+
}
|
|
2428
|
+
function propagate(link) {
|
|
2429
|
+
let next = link.nextSub;
|
|
2430
|
+
let stack;
|
|
2431
|
+
top: do {
|
|
2432
|
+
const sub = link.sub;
|
|
2433
|
+
let flags = sub.flags;
|
|
2434
|
+
if (!(flags & 60)) sub.flags = flags | 32;
|
|
2435
|
+
else if (!(flags & 12)) flags = 0;
|
|
2436
|
+
else if (!(flags & 4)) sub.flags = flags & -9 | 32;
|
|
2437
|
+
else if (!(flags & 48) && isValidLink(link, sub)) {
|
|
2438
|
+
sub.flags = flags | 40;
|
|
2439
|
+
flags &= 1;
|
|
2440
|
+
} else flags = 0;
|
|
2441
|
+
if (flags & 2) notify(sub);
|
|
2442
|
+
if (flags & 1) {
|
|
2443
|
+
const subSubs = sub.subs;
|
|
2444
|
+
if (subSubs !== void 0) {
|
|
2445
|
+
const nextSub = (link = subSubs).nextSub;
|
|
2446
|
+
if (nextSub !== void 0) {
|
|
2447
|
+
stack = {
|
|
2448
|
+
value: next,
|
|
2449
|
+
prev: stack
|
|
2450
|
+
};
|
|
2451
|
+
next = nextSub;
|
|
2452
|
+
}
|
|
2453
|
+
continue;
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
if ((link = next) !== void 0) {
|
|
2457
|
+
next = link.nextSub;
|
|
2458
|
+
continue;
|
|
2459
|
+
}
|
|
2460
|
+
while (stack !== void 0) {
|
|
2461
|
+
link = stack.value;
|
|
2462
|
+
stack = stack.prev;
|
|
2463
|
+
if (link !== void 0) {
|
|
2464
|
+
next = link.nextSub;
|
|
2465
|
+
continue top;
|
|
2466
|
+
}
|
|
2467
|
+
}
|
|
2468
|
+
break;
|
|
2469
|
+
} while (true);
|
|
2470
|
+
}
|
|
2471
|
+
function checkDirty(link, sub) {
|
|
2472
|
+
let stack;
|
|
2473
|
+
let checkDepth = 0;
|
|
2474
|
+
let dirty = false;
|
|
2475
|
+
top: do {
|
|
2476
|
+
const dep = link.dep;
|
|
2477
|
+
const flags = dep.flags;
|
|
2478
|
+
if (sub.flags & 16) dirty = true;
|
|
2479
|
+
else if ((flags & 17) === 17) {
|
|
2480
|
+
if (update(dep)) {
|
|
2481
|
+
const subs = dep.subs;
|
|
2482
|
+
if (subs.nextSub !== void 0) shallowPropagate(subs);
|
|
2483
|
+
dirty = true;
|
|
2484
|
+
}
|
|
2485
|
+
} else if ((flags & 33) === 33) {
|
|
2486
|
+
if (link.nextSub !== void 0 || link.prevSub !== void 0) stack = {
|
|
2487
|
+
value: link,
|
|
2488
|
+
prev: stack
|
|
2489
|
+
};
|
|
2490
|
+
link = dep.deps;
|
|
2491
|
+
sub = dep;
|
|
2492
|
+
++checkDepth;
|
|
2493
|
+
continue;
|
|
2494
|
+
}
|
|
2495
|
+
if (!dirty) {
|
|
2496
|
+
const nextDep = link.nextDep;
|
|
2497
|
+
if (nextDep !== void 0) {
|
|
2498
|
+
link = nextDep;
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
while (checkDepth--) {
|
|
2503
|
+
const firstSub = sub.subs;
|
|
2504
|
+
const hasMultipleSubs = firstSub.nextSub !== void 0;
|
|
2505
|
+
if (hasMultipleSubs) {
|
|
2506
|
+
link = stack.value;
|
|
2507
|
+
stack = stack.prev;
|
|
2508
|
+
} else link = firstSub;
|
|
2509
|
+
if (dirty) {
|
|
2510
|
+
if (update(sub)) {
|
|
2511
|
+
if (hasMultipleSubs) shallowPropagate(firstSub);
|
|
2512
|
+
sub = link.sub;
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
dirty = false;
|
|
2516
|
+
} else sub.flags &= -33;
|
|
2517
|
+
sub = link.sub;
|
|
2518
|
+
const nextDep = link.nextDep;
|
|
2519
|
+
if (nextDep !== void 0) {
|
|
2520
|
+
link = nextDep;
|
|
2521
|
+
continue top;
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
return dirty;
|
|
2525
|
+
} while (true);
|
|
2526
|
+
}
|
|
2527
|
+
function shallowPropagate(link) {
|
|
2528
|
+
do {
|
|
2529
|
+
const sub = link.sub;
|
|
2530
|
+
const flags = sub.flags;
|
|
2531
|
+
if ((flags & 48) === 32) {
|
|
2532
|
+
sub.flags = flags | 16;
|
|
2533
|
+
if ((flags & 6) === 2) notify(sub);
|
|
2534
|
+
}
|
|
2535
|
+
} while ((link = link.nextSub) !== void 0);
|
|
2536
|
+
}
|
|
2537
|
+
function isValidLink(checkLink, sub) {
|
|
2538
|
+
let link = sub.depsTail;
|
|
2539
|
+
while (link !== void 0) {
|
|
2540
|
+
if (link === checkLink) return true;
|
|
2541
|
+
link = link.prevDep;
|
|
2542
|
+
}
|
|
2543
|
+
return false;
|
|
2544
|
+
}
|
|
2545
|
+
}
|
|
2546
|
+
//#endregion
|
|
2547
|
+
//#region ../../node_modules/.bun/alien-signals@3.1.2/node_modules/alien-signals/esm/index.mjs
|
|
2548
|
+
let cycle = 0;
|
|
2549
|
+
let notifyIndex = 0;
|
|
2550
|
+
let queuedLength = 0;
|
|
2551
|
+
let activeSub;
|
|
2552
|
+
const queued = [];
|
|
2553
|
+
const { link, unlink, propagate, checkDirty, shallowPropagate } = createReactiveSystem({
|
|
2554
|
+
update(node) {
|
|
2555
|
+
if (node.depsTail !== void 0) return updateComputed(node);
|
|
2556
|
+
else return updateSignal(node);
|
|
2557
|
+
},
|
|
2558
|
+
notify(effect) {
|
|
2559
|
+
let insertIndex = queuedLength;
|
|
2560
|
+
let firstInsertedIndex = insertIndex;
|
|
2561
|
+
do {
|
|
2562
|
+
queued[insertIndex++] = effect;
|
|
2563
|
+
effect.flags &= -3;
|
|
2564
|
+
effect = effect.subs?.sub;
|
|
2565
|
+
if (effect === void 0 || !(effect.flags & 2)) break;
|
|
2566
|
+
} while (true);
|
|
2567
|
+
queuedLength = insertIndex;
|
|
2568
|
+
while (firstInsertedIndex < --insertIndex) {
|
|
2569
|
+
const left = queued[firstInsertedIndex];
|
|
2570
|
+
queued[firstInsertedIndex++] = queued[insertIndex];
|
|
2571
|
+
queued[insertIndex] = left;
|
|
2572
|
+
}
|
|
2573
|
+
},
|
|
2574
|
+
unwatched(node) {
|
|
2575
|
+
if (!(node.flags & 1)) effectScopeOper.call(node);
|
|
2576
|
+
else if (node.depsTail !== void 0) {
|
|
2577
|
+
node.depsTail = void 0;
|
|
2578
|
+
node.flags = 17;
|
|
2579
|
+
purgeDeps(node);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
function setActiveSub(sub) {
|
|
2584
|
+
const prevSub = activeSub;
|
|
2585
|
+
activeSub = sub;
|
|
2586
|
+
return prevSub;
|
|
2587
|
+
}
|
|
2588
|
+
function signal(initialValue) {
|
|
2589
|
+
return signalOper.bind({
|
|
2590
|
+
currentValue: initialValue,
|
|
2591
|
+
pendingValue: initialValue,
|
|
2592
|
+
subs: void 0,
|
|
2593
|
+
subsTail: void 0,
|
|
2594
|
+
flags: 1
|
|
2595
|
+
});
|
|
2596
|
+
}
|
|
2597
|
+
function computed(getter) {
|
|
2598
|
+
return computedOper.bind({
|
|
2599
|
+
value: void 0,
|
|
2600
|
+
subs: void 0,
|
|
2601
|
+
subsTail: void 0,
|
|
2602
|
+
deps: void 0,
|
|
2603
|
+
depsTail: void 0,
|
|
2604
|
+
flags: 0,
|
|
2605
|
+
getter
|
|
2606
|
+
});
|
|
2607
|
+
}
|
|
2608
|
+
function updateComputed(c) {
|
|
2609
|
+
++cycle;
|
|
2610
|
+
c.depsTail = void 0;
|
|
2611
|
+
c.flags = 5;
|
|
2612
|
+
const prevSub = setActiveSub(c);
|
|
2613
|
+
try {
|
|
2614
|
+
const oldValue = c.value;
|
|
2615
|
+
return oldValue !== (c.value = c.getter(oldValue));
|
|
2616
|
+
} finally {
|
|
2617
|
+
activeSub = prevSub;
|
|
2618
|
+
c.flags &= -5;
|
|
2619
|
+
purgeDeps(c);
|
|
2620
|
+
}
|
|
2621
|
+
}
|
|
2622
|
+
function updateSignal(s) {
|
|
2623
|
+
s.flags = 1;
|
|
2624
|
+
return s.currentValue !== (s.currentValue = s.pendingValue);
|
|
2625
|
+
}
|
|
2626
|
+
function run(e) {
|
|
2627
|
+
const flags = e.flags;
|
|
2628
|
+
if (flags & 16 || flags & 32 && checkDirty(e.deps, e)) {
|
|
2629
|
+
++cycle;
|
|
2630
|
+
e.depsTail = void 0;
|
|
2631
|
+
e.flags = 6;
|
|
2632
|
+
const prevSub = setActiveSub(e);
|
|
2633
|
+
try {
|
|
2634
|
+
e.fn();
|
|
2635
|
+
} finally {
|
|
2636
|
+
activeSub = prevSub;
|
|
2637
|
+
e.flags &= -5;
|
|
2638
|
+
purgeDeps(e);
|
|
2639
|
+
}
|
|
2640
|
+
} else e.flags = 2;
|
|
2641
|
+
}
|
|
2642
|
+
function flush() {
|
|
2643
|
+
try {
|
|
2644
|
+
while (notifyIndex < queuedLength) {
|
|
2645
|
+
const effect = queued[notifyIndex];
|
|
2646
|
+
queued[notifyIndex++] = void 0;
|
|
2647
|
+
run(effect);
|
|
2648
|
+
}
|
|
2649
|
+
} finally {
|
|
2650
|
+
while (notifyIndex < queuedLength) {
|
|
2651
|
+
const effect = queued[notifyIndex];
|
|
2652
|
+
queued[notifyIndex++] = void 0;
|
|
2653
|
+
effect.flags |= 10;
|
|
2654
|
+
}
|
|
2655
|
+
notifyIndex = 0;
|
|
2656
|
+
queuedLength = 0;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
function computedOper() {
|
|
2660
|
+
const flags = this.flags;
|
|
2661
|
+
if (flags & 16 || flags & 32 && (checkDirty(this.deps, this) || (this.flags = flags & -33, false))) {
|
|
2662
|
+
if (updateComputed(this)) {
|
|
2663
|
+
const subs = this.subs;
|
|
2664
|
+
if (subs !== void 0) shallowPropagate(subs);
|
|
2665
|
+
}
|
|
2666
|
+
} else if (!flags) {
|
|
2667
|
+
this.flags = 5;
|
|
2668
|
+
const prevSub = setActiveSub(this);
|
|
2669
|
+
try {
|
|
2670
|
+
this.value = this.getter();
|
|
2671
|
+
} finally {
|
|
2672
|
+
activeSub = prevSub;
|
|
2673
|
+
this.flags &= -5;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
const sub = activeSub;
|
|
2677
|
+
if (sub !== void 0) link(this, sub, cycle);
|
|
2678
|
+
return this.value;
|
|
2679
|
+
}
|
|
2680
|
+
function signalOper(...value) {
|
|
2681
|
+
if (value.length) {
|
|
2682
|
+
if (this.pendingValue !== (this.pendingValue = value[0])) {
|
|
2683
|
+
this.flags = 17;
|
|
2684
|
+
const subs = this.subs;
|
|
2685
|
+
if (subs !== void 0) {
|
|
2686
|
+
propagate(subs);
|
|
2687
|
+
flush();
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
} else {
|
|
2691
|
+
if (this.flags & 16) {
|
|
2692
|
+
if (updateSignal(this)) {
|
|
2693
|
+
const subs = this.subs;
|
|
2694
|
+
if (subs !== void 0) shallowPropagate(subs);
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
let sub = activeSub;
|
|
2698
|
+
while (sub !== void 0) {
|
|
2699
|
+
if (sub.flags & 3) {
|
|
2700
|
+
link(this, sub, cycle);
|
|
2701
|
+
break;
|
|
2702
|
+
}
|
|
2703
|
+
sub = sub.subs?.sub;
|
|
2704
|
+
}
|
|
2705
|
+
return this.currentValue;
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
function effectScopeOper() {
|
|
2709
|
+
this.depsTail = void 0;
|
|
2710
|
+
this.flags = 0;
|
|
2711
|
+
purgeDeps(this);
|
|
2712
|
+
const sub = this.subs;
|
|
2713
|
+
if (sub !== void 0) unlink(sub);
|
|
2714
|
+
}
|
|
2715
|
+
function purgeDeps(sub) {
|
|
2716
|
+
const depsTail = sub.depsTail;
|
|
2717
|
+
let dep = depsTail !== void 0 ? depsTail.nextDep : sub.deps;
|
|
2718
|
+
while (dep !== void 0) dep = unlink(dep, sub);
|
|
2719
|
+
}
|
|
2720
|
+
//#endregion
|
|
2721
|
+
//#region packages/ag-term/src/pipeline/reactive-node.ts
|
|
2722
|
+
/**
|
|
2723
|
+
* Reactive Node State — alien-signals wrappers for cascade derivations.
|
|
2724
|
+
*
|
|
2725
|
+
* E+ Phase 2: Replace manual cascade formula computation with reactive
|
|
2726
|
+
* computeds. The cascade-predicates.ts `computeCascade()` function is the
|
|
2727
|
+
* oracle — this module must produce identical outputs.
|
|
2728
|
+
*
|
|
2729
|
+
* Incremental approach:
|
|
2730
|
+
* 1. Writable signals mirror epoch-stamped dirty flags
|
|
2731
|
+
* 2. Computeds derive cascade outputs (isDirty, contentAreaAffected, etc.)
|
|
2732
|
+
* 3. `syncToSignals()` bridges epoch flags → signals at render-phase entry
|
|
2733
|
+
* 4. Dev-mode assertions verify reactive === oracle
|
|
2734
|
+
*
|
|
2735
|
+
* alien-signals computeds are lazy (pull-based): reading a computed re-evaluates
|
|
2736
|
+
* only if a dependency changed. No batching needed for correctness — the
|
|
2737
|
+
* reconciler's synchronous commit writes epoch stamps, and the render phase
|
|
2738
|
+
* reads computeds after all commits are done.
|
|
2739
|
+
*/
|
|
2740
|
+
/**
|
|
2741
|
+
* Create a reactive state wrapper for a node.
|
|
2742
|
+
*
|
|
2743
|
+
* The computed formulas exactly replicate cascade-predicates.ts `computeCascade()`.
|
|
2744
|
+
* They are NOT a replacement — the oracle function remains authoritative.
|
|
2745
|
+
* In dev mode, assertions verify equivalence.
|
|
2746
|
+
*/
|
|
2747
|
+
function createReactiveNodeState() {
|
|
2748
|
+
const contentDirty = signal(false);
|
|
2749
|
+
const stylePropsDirty = signal(false);
|
|
2750
|
+
const bgDirty = signal(false);
|
|
2751
|
+
const childrenDirty = signal(false);
|
|
2752
|
+
const subtreeDirty = signal(false);
|
|
2753
|
+
const layoutChanged = signal(false);
|
|
2754
|
+
const hasPrevBuffer = signal(false);
|
|
2755
|
+
const childPositionChanged = signal(false);
|
|
2756
|
+
const ancestorLayoutChanged = signal(false);
|
|
2757
|
+
const ancestorCleared = signal(false);
|
|
2758
|
+
const isTextNode = signal(false);
|
|
2759
|
+
const hasBgColor = signal(false);
|
|
2760
|
+
const absoluteChildMutated = signal(false);
|
|
2761
|
+
const descendantOverflowChanged = signal(false);
|
|
2762
|
+
const canSkipEntireSubtree = computed(() => hasPrevBuffer() && !contentDirty() && !stylePropsDirty() && !layoutChanged() && !subtreeDirty() && !childrenDirty() && !childPositionChanged() && !ancestorLayoutChanged());
|
|
2763
|
+
const textPaintDirty = computed(() => isTextNode() && stylePropsDirty());
|
|
2764
|
+
const contentAreaAffected = computed(() => contentDirty() || layoutChanged() || childPositionChanged() || childrenDirty() || bgDirty() || textPaintDirty() || absoluteChildMutated() || descendantOverflowChanged());
|
|
2765
|
+
const bgOnlyChange = computed(() => false);
|
|
2766
|
+
const bgRefillNeeded = computed(() => hasPrevBuffer() && !contentAreaAffected() && subtreeDirty() && hasBgColor());
|
|
2767
|
+
return {
|
|
2768
|
+
contentDirty,
|
|
2769
|
+
stylePropsDirty,
|
|
2770
|
+
bgDirty,
|
|
2771
|
+
childrenDirty,
|
|
2772
|
+
subtreeDirty,
|
|
2773
|
+
layoutChanged,
|
|
2774
|
+
hasPrevBuffer,
|
|
2775
|
+
childPositionChanged,
|
|
2776
|
+
ancestorLayoutChanged,
|
|
2777
|
+
ancestorCleared,
|
|
2778
|
+
isTextNode,
|
|
2779
|
+
hasBgColor,
|
|
2780
|
+
absoluteChildMutated,
|
|
2781
|
+
descendantOverflowChanged,
|
|
2782
|
+
canSkipEntireSubtree,
|
|
2783
|
+
textPaintDirty,
|
|
2784
|
+
contentAreaAffected,
|
|
2785
|
+
bgRefillNeeded,
|
|
2786
|
+
contentRegionCleared: computed(() => (hasPrevBuffer() || ancestorCleared()) && contentAreaAffected() && !hasBgColor()),
|
|
2787
|
+
skipBgFill: computed(() => hasPrevBuffer() && !ancestorCleared() && !contentAreaAffected() && !bgRefillNeeded()),
|
|
2788
|
+
childrenNeedFreshRender: computed(() => (hasPrevBuffer() || ancestorCleared()) && (contentAreaAffected() || bgRefillNeeded()) && !bgOnlyChange()),
|
|
2789
|
+
bgOnlyChange
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Sync a node's epoch-stamped dirty flags into the reactive signals.
|
|
2794
|
+
*
|
|
2795
|
+
* Called once per node at the start of renderNodeToBuffer, BEFORE reading
|
|
2796
|
+
* any computed. Context-dependent inputs (hasPrevBuffer, ancestorCleared, etc.)
|
|
2797
|
+
* are also set here since they vary per tree-traversal position.
|
|
2798
|
+
*/
|
|
2799
|
+
function syncToSignals(state, node, ctx) {
|
|
2800
|
+
state.contentDirty(isDirty(node.dirtyBits, node.dirtyEpoch, 1));
|
|
2801
|
+
state.stylePropsDirty(isDirty(node.dirtyBits, node.dirtyEpoch, 2));
|
|
2802
|
+
state.bgDirty(isDirty(node.dirtyBits, node.dirtyEpoch, 4));
|
|
2803
|
+
state.childrenDirty(isDirty(node.dirtyBits, node.dirtyEpoch, 8));
|
|
2804
|
+
state.subtreeDirty(isDirty(node.dirtyBits, node.dirtyEpoch, 16));
|
|
2805
|
+
state.layoutChanged(ctx.layoutChanged);
|
|
2806
|
+
state.hasPrevBuffer(ctx.hasPrevBuffer);
|
|
2807
|
+
state.childPositionChanged(ctx.childPositionChanged);
|
|
2808
|
+
state.ancestorLayoutChanged(ctx.ancestorLayoutChanged);
|
|
2809
|
+
state.ancestorCleared(ctx.ancestorCleared);
|
|
2810
|
+
state.isTextNode(node.type === "silvery-text");
|
|
2811
|
+
state.hasBgColor(ctx.hasBgColor);
|
|
2812
|
+
state.absoluteChildMutated(ctx.absoluteChildMutated);
|
|
2813
|
+
state.descendantOverflowChanged(ctx.descendantOverflowChanged);
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Read all cascade outputs from the reactive computeds.
|
|
2817
|
+
* Call AFTER `syncToSignals()`. Returns the same CascadeOutputs shape
|
|
2818
|
+
* as `computeCascade()` but derived from the signal graph.
|
|
2819
|
+
*/
|
|
2820
|
+
function readReactiveCascade(state) {
|
|
2821
|
+
return {
|
|
2822
|
+
canSkipEntireSubtree: state.canSkipEntireSubtree(),
|
|
2823
|
+
contentAreaAffected: state.contentAreaAffected(),
|
|
2824
|
+
bgRefillNeeded: state.bgRefillNeeded(),
|
|
2825
|
+
contentRegionCleared: state.contentRegionCleared(),
|
|
2826
|
+
skipBgFill: state.skipBgFill(),
|
|
2827
|
+
childrenNeedFreshRender: state.childrenNeedFreshRender(),
|
|
2828
|
+
bgOnlyChange: state.bgOnlyChange()
|
|
2829
|
+
};
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Verify that reactive computeds match the cascade oracle.
|
|
2833
|
+
*
|
|
2834
|
+
* Call in dev mode (SILVERY_STRICT=1) after syncToSignals + computeCascade.
|
|
2835
|
+
* Throws on mismatch with a detailed diff.
|
|
2836
|
+
*/
|
|
2837
|
+
function assertReactiveMatchesOracle(state, oracle, nodeId) {
|
|
2838
|
+
const fields = [
|
|
2839
|
+
"canSkipEntireSubtree",
|
|
2840
|
+
"contentAreaAffected",
|
|
2841
|
+
"bgRefillNeeded",
|
|
2842
|
+
"contentRegionCleared",
|
|
2843
|
+
"skipBgFill",
|
|
2844
|
+
"childrenNeedFreshRender",
|
|
2845
|
+
"bgOnlyChange"
|
|
2846
|
+
];
|
|
2847
|
+
const mismatches = [];
|
|
2848
|
+
for (const field of fields) {
|
|
2849
|
+
const reactiveValue = state[field]();
|
|
2850
|
+
const oracleValue = oracle[field];
|
|
2851
|
+
if (reactiveValue !== oracleValue) mismatches.push(` ${field}: reactive=${reactiveValue}, oracle=${oracleValue}`);
|
|
2852
|
+
}
|
|
2853
|
+
if (mismatches.length > 0) throw new Error(`ReactiveNodeState mismatch for ${nodeId || "(unnamed)"}:\n${mismatches.join("\n")}`);
|
|
2854
|
+
}
|
|
2855
|
+
/**
|
|
2856
|
+
* WeakMap from AgNode to its reactive state.
|
|
2857
|
+
*
|
|
2858
|
+
* Lazily created on first access. Automatically garbage-collected when the
|
|
2859
|
+
* node is removed from the tree (WeakMap semantics).
|
|
2860
|
+
*/
|
|
2861
|
+
const nodeStates = /* @__PURE__ */ new WeakMap();
|
|
2862
|
+
/**
|
|
2863
|
+
* Get or create the reactive state for a node.
|
|
2864
|
+
*
|
|
2865
|
+
* Uses a WeakMap so states are automatically cleaned up when nodes are GC'd.
|
|
2866
|
+
*/
|
|
2867
|
+
function getReactiveState(node) {
|
|
2868
|
+
let state = nodeStates.get(node);
|
|
2869
|
+
if (!state) {
|
|
2870
|
+
state = createReactiveNodeState();
|
|
2871
|
+
nodeStates.set(node, state);
|
|
2872
|
+
}
|
|
2873
|
+
return state;
|
|
2874
|
+
}
|
|
2875
|
+
//#endregion
|
|
2876
|
+
//#region packages/ag-term/src/pipeline/render-phase.ts
|
|
2877
|
+
/**
|
|
2878
|
+
* Phase 3: Render Phase
|
|
2879
|
+
*
|
|
2880
|
+
* Render all nodes to a terminal buffer.
|
|
2881
|
+
*
|
|
2882
|
+
* This module orchestrates the rendering process by traversing the node tree
|
|
2883
|
+
* and delegating to specialized rendering functions for boxes and text.
|
|
2884
|
+
*
|
|
2885
|
+
* Layout (top-down):
|
|
2886
|
+
* renderPhase → renderNodeToBuffer → buildCascadeInputs + computeCascade (oracle)
|
|
2887
|
+
* → traceRenderDecision (diagnostics)
|
|
2888
|
+
* → executeRegionClearing
|
|
2889
|
+
* → renderOwnContent
|
|
2890
|
+
* → renderScrollContainerChildren / renderNormalChildren
|
|
2891
|
+
* Helpers: clearDirtyFlags, hasChildPositionChanged, computeChildClipBounds
|
|
2892
|
+
* Region clearing: clearNodeRegion, clearExcessArea, clippedFill
|
|
2893
|
+
*/
|
|
2894
|
+
init_buffer();
|
|
2895
|
+
init_state();
|
|
2896
|
+
const contentLog = createLogger("silvery:content");
|
|
2897
|
+
const traceLog = createLogger("silvery:content:trace");
|
|
2898
|
+
const cellLog = createLogger("silvery:content:cell");
|
|
2899
|
+
/**
|
|
2900
|
+
* Render all nodes to a terminal buffer.
|
|
2901
|
+
*
|
|
2902
|
+
* @param root The root SilveryNode
|
|
2903
|
+
* @param prevBuffer Previous buffer for incremental rendering (optional)
|
|
2904
|
+
* @returns A TerminalBuffer with the rendered content
|
|
2905
|
+
*/
|
|
2906
|
+
function renderPhase(root, prevBuffer, ctx) {
|
|
2907
|
+
const layout = root.boxRect;
|
|
2908
|
+
if (!layout) throw new Error("renderPhase called before layout phase");
|
|
2909
|
+
const instr = resolveInstrumentation(ctx);
|
|
2910
|
+
const hasPrevBuffer = prevBuffer && prevBuffer.width === layout.width && prevBuffer.height === layout.height;
|
|
2911
|
+
if (hasPrevBuffer && !isAnyDirty(root.dirtyBits, root.dirtyEpoch) && !isCurrentEpoch(root.layoutChangedThisFrame)) {
|
|
2912
|
+
if (instr.enabled) instr.stats._noopSkip = 1;
|
|
2913
|
+
advanceRenderEpoch();
|
|
2914
|
+
return prevBuffer;
|
|
2915
|
+
}
|
|
2916
|
+
if (instr.enabled) {
|
|
2917
|
+
instr.stats._prevBufferNull = prevBuffer == null ? 1 : 0;
|
|
2918
|
+
instr.stats._prevBufferDimMismatch = prevBuffer && !hasPrevBuffer ? 1 : 0;
|
|
2919
|
+
instr.stats._hasPrevBuffer = hasPrevBuffer ? 1 : 0;
|
|
2920
|
+
instr.stats._layoutW = layout.width;
|
|
2921
|
+
instr.stats._layoutH = layout.height;
|
|
2922
|
+
instr.stats._prevW = prevBuffer?.width ?? 0;
|
|
2923
|
+
instr.stats._prevH = prevBuffer?.height ?? 0;
|
|
2924
|
+
}
|
|
2925
|
+
const t0 = instr.enabled ? performance.now() : 0;
|
|
2926
|
+
const buffer = hasPrevBuffer ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height);
|
|
2927
|
+
const tClone = instr.enabled ? performance.now() - t0 : 0;
|
|
2928
|
+
buffer.setSelectableMode(true);
|
|
2929
|
+
const t1 = instr.enabled ? performance.now() : 0;
|
|
2930
|
+
renderNodeToBuffer(root, buffer, {
|
|
2931
|
+
scrollOffset: 0,
|
|
2932
|
+
clipBounds: void 0,
|
|
2933
|
+
hasPrevBuffer: !!hasPrevBuffer,
|
|
2934
|
+
ancestorCleared: false,
|
|
2935
|
+
bufferIsCloned: !!hasPrevBuffer,
|
|
2936
|
+
ancestorLayoutChanged: false,
|
|
2937
|
+
inheritedBg: {
|
|
2938
|
+
color: null,
|
|
2939
|
+
ancestorRect: null
|
|
2940
|
+
},
|
|
2941
|
+
inheritedFg: null
|
|
2942
|
+
}, ctx);
|
|
2943
|
+
const tRender = instr.enabled ? performance.now() - t1 : 0;
|
|
2944
|
+
if (instr.enabled) emitRenderPhaseStats(instr.stats, instr.nodeTrace, instr.nodeTraceEnabled, tClone, tRender);
|
|
2945
|
+
syncPrevLayout(root, isCurrentEpoch(root.layoutChangedThisFrame) || isDirty(root.dirtyBits, root.dirtyEpoch, 16) || !hasPrevBuffer);
|
|
2946
|
+
advanceRenderEpoch();
|
|
2947
|
+
return buffer;
|
|
2948
|
+
}
|
|
2949
|
+
/**
|
|
2950
|
+
* Sync prevLayout to boxRect for all nodes in the tree.
|
|
2951
|
+
*
|
|
2952
|
+
* Called at the end of each renderPhase pass. This prevents:
|
|
2953
|
+
* 1. The O(N) staleness bug where prevLayout drifts from boxRect
|
|
2954
|
+
* causing !rectEqual to always be true on subsequent frames.
|
|
2955
|
+
* 2. Stale old-bounds references in clearExcessArea on doRender iteration 2+.
|
|
2956
|
+
* 3. Asymmetry between incremental and fresh renders — doFreshRender's layout
|
|
2957
|
+
* phase syncs prevLayout before content, so without this, the real render
|
|
2958
|
+
* has null/stale prevLayout while fresh has synced prevLayout, causing
|
|
2959
|
+
* different cascade behavior (layoutChanged true vs false).
|
|
2960
|
+
*/
|
|
2961
|
+
/**
|
|
2962
|
+
* Sync prevLayout = boxRect for nodes whose layout changed.
|
|
2963
|
+
*
|
|
2964
|
+
* Previously walked ALL nodes O(N) every frame. Now only visits nodes
|
|
2965
|
+
* with layoutChangedThisFrame (set by propagateLayout in layout phase).
|
|
2966
|
+
* Falls back to full walk when layout phase ran (dimensions changed or
|
|
2967
|
+
* layoutDirty) since any node may have moved.
|
|
2968
|
+
*
|
|
2969
|
+
* For cursor move (no layout change): O(1) — no nodes to sync.
|
|
2970
|
+
* For resize: O(N) — all nodes may have moved (same as before).
|
|
2971
|
+
*/
|
|
2972
|
+
function syncPrevLayout(root, layoutPhaseRan) {
|
|
2973
|
+
if (!layoutPhaseRan) return;
|
|
2974
|
+
const stack = [root];
|
|
2975
|
+
while (stack.length > 0) {
|
|
2976
|
+
const node = stack.pop();
|
|
2977
|
+
node.prevLayout = node.boxRect;
|
|
2978
|
+
const children = node.children;
|
|
2979
|
+
for (let i = children.length - 1; i >= 0; i--) stack.push(children[i]);
|
|
2980
|
+
}
|
|
2981
|
+
}
|
|
2982
|
+
/** Check if an env var is truthy (treats "0" and "false" as disabled). */
|
|
2983
|
+
function envTruthy(val) {
|
|
2984
|
+
return !!val && val !== "0" && val !== "false";
|
|
2985
|
+
}
|
|
2986
|
+
/** Instrumentation enabled when SILVERY_STRICT or SILVERY_INSTRUMENT is set */
|
|
2987
|
+
const _instrumentEnabled = typeof process !== "undefined" && (envTruthy(process.env?.SILVERY_STRICT) || envTruthy(process.env?.SILVERY_INSTRUMENT));
|
|
2988
|
+
/** Mutable stats counters — reset after each renderPhase call */
|
|
2989
|
+
const _renderPhaseStats = {
|
|
2990
|
+
nodesVisited: 0,
|
|
2991
|
+
nodesRendered: 0,
|
|
2992
|
+
nodesSkipped: 0,
|
|
2993
|
+
textNodes: 0,
|
|
2994
|
+
boxNodes: 0,
|
|
2995
|
+
clearOps: 0,
|
|
2996
|
+
noPrevBuffer: 0,
|
|
2997
|
+
flagContentDirty: 0,
|
|
2998
|
+
flagStylePropsDirty: 0,
|
|
2999
|
+
flagLayoutChanged: 0,
|
|
3000
|
+
flagSubtreeDirty: 0,
|
|
3001
|
+
flagChildrenDirty: 0,
|
|
3002
|
+
flagChildPositionChanged: 0,
|
|
3003
|
+
flagAncestorLayoutChanged: 0,
|
|
3004
|
+
scrollContainerCount: 0,
|
|
3005
|
+
scrollViewportCleared: 0,
|
|
3006
|
+
scrollClearReason: "",
|
|
3007
|
+
normalChildrenRepaint: 0,
|
|
3008
|
+
normalRepaintReason: "",
|
|
3009
|
+
cascadeMinDepth: 999,
|
|
3010
|
+
cascadeNodes: "",
|
|
3011
|
+
_noopSkip: 0,
|
|
3012
|
+
_prevBufferNull: 0,
|
|
3013
|
+
_prevBufferDimMismatch: 0,
|
|
3014
|
+
_hasPrevBuffer: 0,
|
|
3015
|
+
_layoutW: 0,
|
|
3016
|
+
_layoutH: 0,
|
|
3017
|
+
_prevW: 0,
|
|
3018
|
+
_prevH: 0,
|
|
3019
|
+
_callCount: 0
|
|
3020
|
+
};
|
|
3021
|
+
let _renderPhaseCallCount = 0;
|
|
3022
|
+
/** Module-level node trace (fallback when ctx.nodeTrace is not provided) */
|
|
3023
|
+
const _nodeTrace = [];
|
|
3024
|
+
const _nodeTraceEnabled = typeof process !== "undefined" && envTruthy(process.env?.SILVERY_STRICT);
|
|
3025
|
+
/**
|
|
3026
|
+
* Reactive cascade: alien-signals computeds drive rendering (production path).
|
|
3027
|
+
* SILVERY_REACTIVE=0: fall back to imperative computeCascade() (bench only).
|
|
3028
|
+
* SILVERY_STRICT=1: oracle verifies reactive output matches imperative.
|
|
3029
|
+
*/
|
|
3030
|
+
let _reactiveEnabled = typeof process === "undefined" || process.env?.SILVERY_REACTIVE !== "0";
|
|
3031
|
+
let _reactiveVerifyEnabled = _reactiveEnabled && typeof process !== "undefined" && envTruthy(process.env?.SILVERY_STRICT);
|
|
3032
|
+
/** Resolve instrumentation from PipelineContext or module-level defaults. */
|
|
3033
|
+
function resolveInstrumentation(ctx) {
|
|
3034
|
+
return {
|
|
3035
|
+
enabled: ctx?.instrumentEnabled ?? _instrumentEnabled,
|
|
3036
|
+
stats: ctx?.stats ?? _renderPhaseStats,
|
|
3037
|
+
nodeTrace: ctx?.nodeTrace ?? _nodeTrace,
|
|
3038
|
+
nodeTraceEnabled: ctx?.nodeTraceEnabled ?? _nodeTraceEnabled
|
|
3039
|
+
};
|
|
3040
|
+
}
|
|
3041
|
+
/** Read the cell debug target (set by SILVERY_CELL_DEBUG env var). */
|
|
3042
|
+
function getCellDebug() {
|
|
3043
|
+
return globalThis.__silvery_cell_debug;
|
|
3044
|
+
}
|
|
3045
|
+
/** Check if a rect covers the cell debug target point. */
|
|
3046
|
+
function cellCoversPoint(cellDbg, x, y, width, height) {
|
|
3047
|
+
return x <= cellDbg.x && x + width > cellDbg.x && y <= cellDbg.y && y + height > cellDbg.y;
|
|
3048
|
+
}
|
|
3049
|
+
/** DIAG: compute node depth in tree */
|
|
3050
|
+
function _getNodeDepth(node) {
|
|
3051
|
+
let depth = 0;
|
|
3052
|
+
let n = node.parent;
|
|
3053
|
+
while (n) {
|
|
3054
|
+
depth++;
|
|
3055
|
+
n = n.parent;
|
|
3056
|
+
}
|
|
3057
|
+
return depth;
|
|
3058
|
+
}
|
|
3059
|
+
/**
|
|
3060
|
+
* Emit render-phase stats to globalThis + loggily, then reset counters.
|
|
3061
|
+
* Called at end of renderPhase when instrumentation is active.
|
|
3062
|
+
*/
|
|
3063
|
+
function emitRenderPhaseStats(stats, nodeTrace, nodeTraceEnabled, tClone, tRender) {
|
|
3064
|
+
_renderPhaseCallCount++;
|
|
3065
|
+
stats._callCount = _renderPhaseCallCount;
|
|
3066
|
+
const snap = {
|
|
3067
|
+
clone: tClone,
|
|
3068
|
+
render: tRender,
|
|
3069
|
+
...structuredClone(stats)
|
|
3070
|
+
};
|
|
3071
|
+
globalThis.__silvery_content_detail = snap;
|
|
3072
|
+
(globalThis.__silvery_content_all ??= []).push(snap);
|
|
3073
|
+
contentLog.debug?.(`frame ${snap._callCount}: ${snap.nodesRendered}/${snap.nodesVisited} rendered, ${snap.nodesSkipped} skipped (${tClone.toFixed(1)}ms clone, ${tRender.toFixed(1)}ms render)`);
|
|
3074
|
+
for (const key of Object.keys(stats)) stats[key] = 0;
|
|
3075
|
+
stats.cascadeMinDepth = 999;
|
|
3076
|
+
stats.cascadeNodes = "";
|
|
3077
|
+
stats.scrollClearReason = "";
|
|
3078
|
+
stats.normalRepaintReason = "";
|
|
3079
|
+
if (nodeTraceEnabled && nodeTrace.length > 0) {
|
|
3080
|
+
(globalThis.__silvery_node_trace ??= []).push([...nodeTrace]);
|
|
3081
|
+
traceLog.debug?.(`${nodeTrace.length} nodes traced`);
|
|
3082
|
+
nodeTrace.length = 0;
|
|
3083
|
+
}
|
|
3084
|
+
}
|
|
3085
|
+
/**
|
|
3086
|
+
* Render a single node to the buffer.
|
|
3087
|
+
*/
|
|
3088
|
+
function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
3089
|
+
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged = false } = nodeState;
|
|
3090
|
+
const instr = resolveInstrumentation(ctx);
|
|
3091
|
+
if (instr.enabled) instr.stats.nodesVisited++;
|
|
3092
|
+
const layout = node.boxRect;
|
|
3093
|
+
if (!layout) return;
|
|
3094
|
+
if (!node.layoutNode) {
|
|
3095
|
+
clearVirtualTextFlags(node);
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
if (node.hidden) {
|
|
3099
|
+
clearDirtyFlags(node);
|
|
3100
|
+
return;
|
|
3101
|
+
}
|
|
3102
|
+
const props = node.props;
|
|
3103
|
+
const prevSelectableMode = buffer.getSelectableMode();
|
|
3104
|
+
const userSelect = props.userSelect;
|
|
3105
|
+
if (userSelect === "none") buffer.setSelectableMode(false);
|
|
3106
|
+
else if (userSelect === "text" || userSelect === "contain") buffer.setSelectableMode(true);
|
|
3107
|
+
if (props.display === "none") {
|
|
3108
|
+
clearDirtyFlags(node);
|
|
3109
|
+
buffer.setSelectableMode(prevSelectableMode);
|
|
3110
|
+
return;
|
|
3111
|
+
}
|
|
3112
|
+
const screenY = layout.y - scrollOffset;
|
|
3113
|
+
if (screenY >= buffer.height || screenY + layout.height <= 0) {
|
|
3114
|
+
buffer.setSelectableMode(prevSelectableMode);
|
|
3115
|
+
return;
|
|
3116
|
+
}
|
|
3117
|
+
const layoutChanged = isCurrentEpoch(node.layoutChangedThisFrame);
|
|
3118
|
+
const childPositionChanged = !!(hasPrevBuffer && !layoutChanged && hasChildPositionChanged(node));
|
|
3119
|
+
const scrollOffsetChanged = !!(node.scrollState && node.scrollState.offset !== node.scrollState.prevOffset);
|
|
3120
|
+
const canSkipEntireSubtree = hasPrevBuffer && !isDirty(node.dirtyBits, node.dirtyEpoch, 1) && !isDirty(node.dirtyBits, node.dirtyEpoch, 2) && !layoutChanged && !isDirty(node.dirtyBits, node.dirtyEpoch, 16) && !isDirty(node.dirtyBits, node.dirtyEpoch, 8) && !childPositionChanged && !ancestorLayoutChanged && !scrollOffsetChanged;
|
|
3121
|
+
const _nodeId = instr.enabled ? props.id ?? "" : "";
|
|
3122
|
+
const _traceThis = instr.enabled && instr.nodeTraceEnabled && _nodeId;
|
|
3123
|
+
const _cellDbg = getCellDebug();
|
|
3124
|
+
const _coversCellNow = _cellDbg && cellCoversPoint(_cellDbg, layout.x, screenY, layout.width, layout.height);
|
|
3125
|
+
const _coversCellPrev = _cellDbg && node.prevLayout && cellCoversPoint(_cellDbg, node.prevLayout.x, node.prevLayout.y - scrollOffset, node.prevLayout.width, node.prevLayout.height);
|
|
3126
|
+
if (canSkipEntireSubtree) {
|
|
3127
|
+
if (_cellDbg && (_coversCellNow || _coversCellPrev)) {
|
|
3128
|
+
const id = props.id ?? node.type;
|
|
3129
|
+
const depth = _getNodeDepth(node);
|
|
3130
|
+
const prev = node.prevLayout;
|
|
3131
|
+
const msg = `SKIP ${id}@${depth} rect=${layout.x},${screenY} ${layout.width}x${layout.height} prev=${prev ? `${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` : "null"} coversNow=${_coversCellNow} coversPrev=${_coversCellPrev}`;
|
|
3132
|
+
_cellDbg.log.push(msg);
|
|
3133
|
+
cellLog.debug?.(msg);
|
|
3134
|
+
}
|
|
3135
|
+
if (instr.enabled) {
|
|
3136
|
+
instr.stats.nodesSkipped++;
|
|
3137
|
+
if (_traceThis) instr.nodeTrace.push({
|
|
3138
|
+
id: _nodeId,
|
|
3139
|
+
type: node.type,
|
|
3140
|
+
depth: _getNodeDepth(node),
|
|
3141
|
+
rect: `${layout.x},${layout.y} ${layout.width}x${layout.height}`,
|
|
3142
|
+
prevLayout: node.prevLayout ? `${node.prevLayout.x},${node.prevLayout.y} ${node.prevLayout.width}x${node.prevLayout.height}` : "null",
|
|
3143
|
+
hasPrev: hasPrevBuffer,
|
|
3144
|
+
ancestorCleared,
|
|
3145
|
+
flags: "",
|
|
3146
|
+
decision: "SKIPPED",
|
|
3147
|
+
layoutChanged
|
|
3148
|
+
});
|
|
3149
|
+
}
|
|
3150
|
+
clearDirtyFlags(node);
|
|
3151
|
+
buffer.setSelectableMode(prevSelectableMode);
|
|
3152
|
+
return;
|
|
3153
|
+
}
|
|
3154
|
+
if (instr.enabled) {
|
|
3155
|
+
instr.stats.nodesRendered++;
|
|
3156
|
+
if (!hasPrevBuffer) instr.stats.noPrevBuffer++;
|
|
3157
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, 1)) instr.stats.flagContentDirty++;
|
|
3158
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, 2)) instr.stats.flagStylePropsDirty++;
|
|
3159
|
+
if (layoutChanged) instr.stats.flagLayoutChanged++;
|
|
3160
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, 16)) instr.stats.flagSubtreeDirty++;
|
|
3161
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, 8)) instr.stats.flagChildrenDirty++;
|
|
3162
|
+
if (childPositionChanged) instr.stats.flagChildPositionChanged++;
|
|
3163
|
+
if (ancestorLayoutChanged) instr.stats.flagAncestorLayoutChanged++;
|
|
3164
|
+
}
|
|
3165
|
+
const nodeTheme = props.theme;
|
|
3166
|
+
if (nodeTheme) pushContextTheme(nodeTheme);
|
|
3167
|
+
try {
|
|
3168
|
+
const isScrollContainer = props.overflow === "scroll" && node.scrollState;
|
|
3169
|
+
const { absoluteChildMutated, descendantOverflowChanged } = buildCascadeInputs(node, hasPrevBuffer);
|
|
3170
|
+
const cascadeInputs = {
|
|
3171
|
+
hasPrevBuffer,
|
|
3172
|
+
contentDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 1),
|
|
3173
|
+
stylePropsDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 2),
|
|
3174
|
+
layoutChanged,
|
|
3175
|
+
subtreeDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 16),
|
|
3176
|
+
childrenDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 8),
|
|
3177
|
+
childPositionChanged,
|
|
3178
|
+
ancestorLayoutChanged,
|
|
3179
|
+
ancestorCleared,
|
|
3180
|
+
bgDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 4),
|
|
3181
|
+
isTextNode: node.type === "silvery-text",
|
|
3182
|
+
hasBgColor: !!getEffectiveBg(props),
|
|
3183
|
+
absoluteChildMutated,
|
|
3184
|
+
descendantOverflowChanged
|
|
3185
|
+
};
|
|
3186
|
+
let cascade;
|
|
3187
|
+
if (_reactiveEnabled) {
|
|
3188
|
+
const reactiveState = getReactiveState(node);
|
|
3189
|
+
syncToSignals(reactiveState, node, {
|
|
3190
|
+
hasPrevBuffer,
|
|
3191
|
+
layoutChanged,
|
|
3192
|
+
childPositionChanged,
|
|
3193
|
+
ancestorLayoutChanged,
|
|
3194
|
+
ancestorCleared,
|
|
3195
|
+
absoluteChildMutated,
|
|
3196
|
+
descendantOverflowChanged,
|
|
3197
|
+
hasBgColor: cascadeInputs.hasBgColor
|
|
3198
|
+
});
|
|
3199
|
+
cascade = readReactiveCascade(reactiveState);
|
|
3200
|
+
if (_reactiveVerifyEnabled) assertReactiveMatchesOracle(reactiveState, computeCascade(cascadeInputs), props.id ?? node.type);
|
|
3201
|
+
} else cascade = computeCascade(cascadeInputs);
|
|
3202
|
+
if (cascade.bgOnlyChange && hasDescendantWithBg(node)) {
|
|
3203
|
+
const childrenNeedFreshRender = (hasPrevBuffer || ancestorCleared) && (cascade.contentAreaAffected || cascade.bgRefillNeeded);
|
|
3204
|
+
cascade = {
|
|
3205
|
+
...cascade,
|
|
3206
|
+
bgOnlyChange: false,
|
|
3207
|
+
childrenNeedFreshRender
|
|
3208
|
+
};
|
|
3209
|
+
}
|
|
3210
|
+
const { contentRegionCleared, skipBgFill, childrenNeedFreshRender } = cascade;
|
|
3211
|
+
if (instr.enabled || _cellDbg && (_coversCellNow || _coversCellPrev)) traceRenderDecision(node, props, layout, screenY, scrollOffset, hasPrevBuffer, ancestorCleared, layoutChanged, childPositionChanged, cascade, _nodeId, _traceThis, _cellDbg, _coversCellNow, _coversCellPrev, instr.enabled, instr.stats, instr.nodeTrace);
|
|
3212
|
+
const useTextStyleFastPath = false;
|
|
3213
|
+
executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instr.enabled, instr.stats, nodeState.inheritedBg);
|
|
3214
|
+
const needsOwnRepaint = !hasPrevBuffer || ancestorCleared || ancestorLayoutChanged || cascade.contentAreaAffected || isDirty(node.dirtyBits, node.dirtyEpoch, 2) || cascade.bgRefillNeeded;
|
|
3215
|
+
const boxInheritedBg = node.type === "silvery-box" && !getEffectiveBg(props) ? nodeState.inheritedBg.color : void 0;
|
|
3216
|
+
if (needsOwnRepaint) renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, instr.enabled, instr.stats, ctx, cascade.bgOnlyChange, useTextStyleFastPath);
|
|
3217
|
+
const effectiveBg = getEffectiveBg(props);
|
|
3218
|
+
const childInheritedBg = effectiveBg ? {
|
|
3219
|
+
color: parseColor(effectiveBg),
|
|
3220
|
+
ancestorRect: node.boxRect
|
|
3221
|
+
} : nodeTheme ? {
|
|
3222
|
+
color: parseColor(nodeTheme.bg),
|
|
3223
|
+
ancestorRect: node.boxRect
|
|
3224
|
+
} : nodeState.inheritedBg;
|
|
3225
|
+
const childInheritedFg = props.color ? parseColor(props.color) : nodeTheme ? parseColor(nodeTheme.fg) : nodeState.inheritedFg;
|
|
3226
|
+
const childState = {
|
|
3227
|
+
...nodeState,
|
|
3228
|
+
inheritedBg: childInheritedBg,
|
|
3229
|
+
inheritedFg: childInheritedFg
|
|
3230
|
+
};
|
|
3231
|
+
if (isScrollContainer) {
|
|
3232
|
+
renderScrollContainerChildren(node, buffer, props, childState, contentRegionCleared, childrenNeedFreshRender, ctx);
|
|
3233
|
+
renderScrollIndicators(node, buffer, layout, props, node.scrollState, ctx);
|
|
3234
|
+
} else renderNormalChildren(node, buffer, props, childState, childPositionChanged, contentRegionCleared, childrenNeedFreshRender, ctx);
|
|
3235
|
+
if (node.type === "silvery-box" && props.outlineStyle) {
|
|
3236
|
+
const { x, width, height } = layout;
|
|
3237
|
+
renderOutline(buffer, x, layout.y - scrollOffset, width, height, props, clipBounds, boxInheritedBg);
|
|
3238
|
+
}
|
|
3239
|
+
clearNodeDirtyFlags(node);
|
|
3240
|
+
} finally {
|
|
3241
|
+
if (nodeTheme) popContextTheme();
|
|
3242
|
+
buffer.setSelectableMode(prevSelectableMode);
|
|
3243
|
+
}
|
|
3244
|
+
}
|
|
3245
|
+
/**
|
|
3246
|
+
* Build tree-dependent cascade inputs that require child traversal.
|
|
3247
|
+
*
|
|
3248
|
+
* These feed into computeCascade() alongside the node's own dirty flags.
|
|
3249
|
+
* Separated from renderNodeToBuffer to keep the main function focused on
|
|
3250
|
+
* the rendering flow rather than child traversal details.
|
|
3251
|
+
*/
|
|
3252
|
+
function buildCascadeInputs(node, hasPrevBuffer) {
|
|
3253
|
+
if (!hasPrevBuffer || !isDirty(node.dirtyBits, node.dirtyEpoch, 16) || node.children === void 0) return {
|
|
3254
|
+
absoluteChildMutated: false,
|
|
3255
|
+
descendantOverflowChanged: false
|
|
3256
|
+
};
|
|
3257
|
+
return {
|
|
3258
|
+
absoluteChildMutated: isDirty(node.dirtyBits, node.dirtyEpoch, 32),
|
|
3259
|
+
descendantOverflowChanged: isDirty(node.dirtyBits, node.dirtyEpoch, 64)
|
|
3260
|
+
};
|
|
3261
|
+
}
|
|
3262
|
+
/**
|
|
3263
|
+
* Log per-node trace, cascade tracking, and cell debug info.
|
|
3264
|
+
*
|
|
3265
|
+
* Gated on instrumentation or cell debug being active. Separated from
|
|
3266
|
+
* renderNodeToBuffer to keep the main function focused on rendering logic.
|
|
3267
|
+
*/
|
|
3268
|
+
function traceRenderDecision(node, props, layout, screenY, scrollOffset, hasPrevBuffer, ancestorCleared, layoutChanged, childPositionChanged, cascade, _nodeId, _traceThis, _cellDbg, _coversCellNow, _coversCellPrev, instrumentEnabled, stats, nodeTrace) {
|
|
3269
|
+
const { contentAreaAffected, contentRegionCleared, skipBgFill, childrenNeedFreshRender } = cascade;
|
|
3270
|
+
if (instrumentEnabled) {
|
|
3271
|
+
if (_traceThis) {
|
|
3272
|
+
const flagStr = [
|
|
3273
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 1) && "C",
|
|
3274
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 2) && "P",
|
|
3275
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 4) && "B",
|
|
3276
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 16) && "S",
|
|
3277
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 8) && "Ch",
|
|
3278
|
+
childPositionChanged && "CP"
|
|
3279
|
+
].filter(Boolean).join(",");
|
|
3280
|
+
const childHasPrev_ = isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childPositionChanged || childrenNeedFreshRender ? false : hasPrevBuffer;
|
|
3281
|
+
const childAncestorCleared_ = contentRegionCleared || ancestorCleared && !getEffectiveBg(props);
|
|
3282
|
+
nodeTrace.push({
|
|
3283
|
+
id: _nodeId,
|
|
3284
|
+
type: node.type,
|
|
3285
|
+
depth: _getNodeDepth(node),
|
|
3286
|
+
rect: `${layout.x},${layout.y} ${layout.width}x${layout.height}`,
|
|
3287
|
+
prevLayout: node.prevLayout ? `${node.prevLayout.x},${node.prevLayout.y} ${node.prevLayout.width}x${node.prevLayout.height}` : "null",
|
|
3288
|
+
hasPrev: hasPrevBuffer,
|
|
3289
|
+
ancestorCleared,
|
|
3290
|
+
flags: flagStr,
|
|
3291
|
+
decision: "RENDER",
|
|
3292
|
+
layoutChanged,
|
|
3293
|
+
contentAreaAffected,
|
|
3294
|
+
contentRegionCleared,
|
|
3295
|
+
childrenNeedFreshRender,
|
|
3296
|
+
childHasPrev: childHasPrev_,
|
|
3297
|
+
childAncestorCleared: childAncestorCleared_,
|
|
3298
|
+
skipBgFill,
|
|
3299
|
+
bgColor: props.backgroundColor
|
|
3300
|
+
});
|
|
3301
|
+
}
|
|
3302
|
+
if (childrenNeedFreshRender && node.children.length > 0) {
|
|
3303
|
+
const depth = _getNodeDepth(node);
|
|
3304
|
+
if (depth < stats.cascadeMinDepth) stats.cascadeMinDepth = depth;
|
|
3305
|
+
const entry = `${node.props.id ?? node.type}@${depth}[${[
|
|
3306
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 1) && "C",
|
|
3307
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 2) && "P",
|
|
3308
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 8) && "Ch",
|
|
3309
|
+
layoutChanged && "L",
|
|
3310
|
+
childPositionChanged && "CP"
|
|
3311
|
+
].filter(Boolean).join("")}:${node.children.length}ch]`;
|
|
3312
|
+
stats.cascadeNodes += (stats.cascadeNodes ? " " : "") + entry;
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
if (_cellDbg && (_coversCellNow || _coversCellPrev)) {
|
|
3316
|
+
const id = props.id ?? node.type;
|
|
3317
|
+
const depth = _getNodeDepth(node);
|
|
3318
|
+
const prev = node.prevLayout;
|
|
3319
|
+
const flags = [
|
|
3320
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 1) && "C",
|
|
3321
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 2) && "P",
|
|
3322
|
+
layoutChanged && "L",
|
|
3323
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 16) && "S",
|
|
3324
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 8) && "Ch",
|
|
3325
|
+
childPositionChanged && "CP",
|
|
3326
|
+
isDirty(node.dirtyBits, node.dirtyEpoch, 4) && "B"
|
|
3327
|
+
].filter(Boolean).join(",");
|
|
3328
|
+
const msg = `RENDER ${id}@${depth} rect=${layout.x},${screenY} ${layout.width}x${layout.height} prev=${prev ? `${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height}` : "null"} flags=[${flags}] hasPrev=${hasPrevBuffer} ancClr=${ancestorCleared} caa=${contentAreaAffected} prc=${contentRegionCleared} prm=${childrenNeedFreshRender} coversNow=${_coversCellNow} coversPrev=${_coversCellPrev} bg=${props.backgroundColor ?? "none"}`;
|
|
3329
|
+
_cellDbg.log.push(msg);
|
|
3330
|
+
cellLog.debug?.(msg);
|
|
3331
|
+
}
|
|
3332
|
+
}
|
|
3333
|
+
/**
|
|
3334
|
+
* Handle all region clearing before rendering own content.
|
|
3335
|
+
*
|
|
3336
|
+
* Three clearing paths:
|
|
3337
|
+
* 1. contentRegionCleared: clear the node's region with inherited bg (no own bg)
|
|
3338
|
+
* 2. Excess area: clear stale pixels when a node shrank (even without contentRegionCleared)
|
|
3339
|
+
* 3. Descendant overflow: clear areas where descendants previously overflowed this node's rect
|
|
3340
|
+
*
|
|
3341
|
+
* All clearing runs BEFORE renderBox/renderText so borders drawn later are not overwritten.
|
|
3342
|
+
*/
|
|
3343
|
+
function executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instrumentEnabled, stats, threadedInheritedBg) {
|
|
3344
|
+
if (contentRegionCleared) {
|
|
3345
|
+
if (instrumentEnabled) stats.clearOps++;
|
|
3346
|
+
clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, threadedInheritedBg);
|
|
3347
|
+
} else if (bufferIsCloned && layoutChanged && node.prevLayout) clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, threadedInheritedBg);
|
|
3348
|
+
if (descendantOverflowChanged) clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds, threadedInheritedBg);
|
|
3349
|
+
}
|
|
3350
|
+
/**
|
|
3351
|
+
* Render this node's own content (box background/border or text).
|
|
3352
|
+
*
|
|
3353
|
+
* For boxes: computes inherited bg for border rendering and calls renderBox.
|
|
3354
|
+
* For text: computes inherited bg/fg for text rendering and calls renderText.
|
|
3355
|
+
*
|
|
3356
|
+
* @returns The boxInheritedBg color (needed by outline rendering after children).
|
|
3357
|
+
*/
|
|
3358
|
+
function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, instrumentEnabled, stats, ctx, bgOnlyChange = false, useTextStyleFastPath = false) {
|
|
3359
|
+
const boxInheritedBg = node.type === "silvery-box" && !getEffectiveBg(props) ? nodeState.inheritedBg.color : void 0;
|
|
3360
|
+
if (node.type === "silvery-box") {
|
|
3361
|
+
if (instrumentEnabled) stats.boxNodes++;
|
|
3362
|
+
renderBox(node, buffer, layout, props, nodeState, skipBgFill, boxInheritedBg, bgOnlyChange);
|
|
3363
|
+
} else if (node.type === "silvery-text") {
|
|
3364
|
+
if (instrumentEnabled) stats.textNodes++;
|
|
3365
|
+
const textInheritedBg = nodeState.inheritedBg.color;
|
|
3366
|
+
const textInheritedFg = nodeState.inheritedFg;
|
|
3367
|
+
if (useTextStyleFastPath) {
|
|
3368
|
+
const style = getTextStyle(props);
|
|
3369
|
+
if (style.fg === null && textInheritedFg !== void 0) style.fg = textInheritedFg;
|
|
3370
|
+
const effectiveBg = style.bg !== null ? style.bg : textInheritedBg ?? null;
|
|
3371
|
+
const { x, width, height } = layout;
|
|
3372
|
+
const y = layout.y - nodeState.scrollOffset;
|
|
3373
|
+
buffer.restyleRegion(x, y, width, height, {
|
|
3374
|
+
fg: style.fg,
|
|
3375
|
+
bg: effectiveBg,
|
|
3376
|
+
underlineColor: style.underlineColor ?? null,
|
|
3377
|
+
attrs: style.attrs
|
|
3378
|
+
});
|
|
3379
|
+
} else renderText(node, buffer, layout, props, nodeState, textInheritedBg, textInheritedFg, ctx);
|
|
3380
|
+
}
|
|
3381
|
+
return boxInheritedBg;
|
|
3382
|
+
}
|
|
3383
|
+
/**
|
|
3384
|
+
* Determine the scroll tier strategy for this frame.
|
|
3385
|
+
*
|
|
3386
|
+
* Pure function -- no side effects, no node access beyond the inputs.
|
|
3387
|
+
*
|
|
3388
|
+
* Three-tier strategy:
|
|
3389
|
+
* 1. **shift**: Only scroll offset changed, no sticky children. Buffer contents
|
|
3390
|
+
* shifted by scroll delta; only newly visible edges re-render.
|
|
3391
|
+
* 2. **clear**: Children restructured, visible range changed with scroll, or
|
|
3392
|
+
* parent region changed. Entire viewport cleared and all children re-render.
|
|
3393
|
+
* 3. **subtree-only**: Only some descendants changed. Children use hasPrevBuffer=true
|
|
3394
|
+
* and skip via fast-path if clean. With sticky children, forces all first-pass
|
|
3395
|
+
* items to re-render (stickyForceRefresh).
|
|
3396
|
+
*/
|
|
3397
|
+
function planScrollRender(inputs) {
|
|
3398
|
+
const { scrollOffsetChanged, visibleRangeChanged, hasStickyChildren, childrenNeedFreshRender, childrenDirty, hasPrevBuffer, ancestorCleared, contentRegionCleared, scrollBg } = inputs;
|
|
3399
|
+
const scrollOnly = hasPrevBuffer && scrollOffsetChanged && !childrenDirty && !childrenNeedFreshRender && !hasStickyChildren && !visibleRangeChanged;
|
|
3400
|
+
const needsViewportClear = hasPrevBuffer && !scrollOnly && (scrollOffsetChanged || childrenDirty || childrenNeedFreshRender || visibleRangeChanged);
|
|
3401
|
+
const stickyForceRefresh = hasStickyChildren && hasPrevBuffer && !needsViewportClear;
|
|
3402
|
+
const reasons = [];
|
|
3403
|
+
if (scrollOnly) reasons.push("SHIFT");
|
|
3404
|
+
if (needsViewportClear) {
|
|
3405
|
+
if (scrollOffsetChanged) reasons.push("scrollOffset");
|
|
3406
|
+
if (childrenDirty) reasons.push("childrenDirty");
|
|
3407
|
+
if (childrenNeedFreshRender) reasons.push("childrenNeedFreshRender");
|
|
3408
|
+
if (visibleRangeChanged) reasons.push("visibleRangeChanged");
|
|
3409
|
+
}
|
|
3410
|
+
if (stickyForceRefresh) reasons.push("stickyForceRefresh");
|
|
3411
|
+
return {
|
|
3412
|
+
tier: scrollOnly ? "shift" : needsViewportClear ? "clear" : "subtree-only",
|
|
3413
|
+
clearBg: scrollOnly || needsViewportClear ? scrollBg : null,
|
|
3414
|
+
childHasPrev: needsViewportClear ? false : hasPrevBuffer,
|
|
3415
|
+
childAncestorCleared: needsViewportClear ? true : ancestorCleared || contentRegionCleared,
|
|
3416
|
+
stickyForceRefresh,
|
|
3417
|
+
reasons
|
|
3418
|
+
};
|
|
3419
|
+
}
|
|
3420
|
+
/**
|
|
3421
|
+
* Render children of a scroll container with proper clipping and offset.
|
|
3422
|
+
*/
|
|
3423
|
+
function renderScrollContainerChildren(node, buffer, props, nodeState, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
|
|
3424
|
+
const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg } = nodeState;
|
|
3425
|
+
const instr = resolveInstrumentation(ctx);
|
|
3426
|
+
const layout = node.boxRect;
|
|
3427
|
+
const ss = node.scrollState;
|
|
3428
|
+
if (!layout || !ss) return;
|
|
3429
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
3430
|
+
top: 0,
|
|
3431
|
+
bottom: 0,
|
|
3432
|
+
left: 0,
|
|
3433
|
+
right: 0
|
|
3434
|
+
};
|
|
3435
|
+
const padding = getPadding(props);
|
|
3436
|
+
const childClipBounds = computeChildClipBounds(layout, props, clipBounds, 0, false, true);
|
|
3437
|
+
const scrollOffsetChanged = ss.offset !== ss.prevOffset;
|
|
3438
|
+
const hasStickyChildren = !!(ss.stickyChildren && ss.stickyChildren.length > 0);
|
|
3439
|
+
const visibleRangeChanged = ss.firstVisibleChild !== ss.prevFirstVisibleChild || ss.lastVisibleChild !== ss.prevLastVisibleChild;
|
|
3440
|
+
const clearY = childClipBounds.top;
|
|
3441
|
+
const clearHeight = childClipBounds.bottom - childClipBounds.top;
|
|
3442
|
+
const contentX = layout.x + border.left + padding.left;
|
|
3443
|
+
const contentWidth = layout.width - border.left - border.right - padding.left - padding.right;
|
|
3444
|
+
const scrollBg = scrollOffsetChanged || isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childrenNeedFreshRender || visibleRangeChanged ? getEffectiveBg(props) ? parseColor(getEffectiveBg(props)) : inheritedBg.color : null;
|
|
3445
|
+
const plan = planScrollRender({
|
|
3446
|
+
scrollOffsetChanged,
|
|
3447
|
+
visibleRangeChanged,
|
|
3448
|
+
hasStickyChildren,
|
|
3449
|
+
childrenNeedFreshRender,
|
|
3450
|
+
childrenDirty: isDirty(node.dirtyBits, node.dirtyEpoch, 8),
|
|
3451
|
+
hasPrevBuffer,
|
|
3452
|
+
ancestorCleared,
|
|
3453
|
+
contentRegionCleared,
|
|
3454
|
+
scrollBg
|
|
3455
|
+
});
|
|
3456
|
+
const { tier, stickyForceRefresh } = plan;
|
|
3457
|
+
const defaultChildHasPrev = plan.childHasPrev;
|
|
3458
|
+
const defaultChildAncestorCleared = plan.childAncestorCleared;
|
|
3459
|
+
if (instr.enabled) {
|
|
3460
|
+
instr.stats.scrollContainerCount++;
|
|
3461
|
+
if (tier !== "subtree-only" || stickyForceRefresh) {
|
|
3462
|
+
instr.stats.scrollViewportCleared++;
|
|
3463
|
+
const reasons = [...plan.reasons];
|
|
3464
|
+
if (scrollOffsetChanged) reasons.push(`scrollOffset(${ss.prevOffset}->${ss.offset})`);
|
|
3465
|
+
reasons.push(`vp=${ss.viewportHeight} content=${ss.contentHeight} vis=${ss.firstVisibleChild}-${ss.lastVisibleChild}`);
|
|
3466
|
+
instr.stats.scrollClearReason = reasons.join("+");
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
if (process?.env?.SILVERY_STRICT && tier === "shift" && hasStickyChildren) throw new Error(`[SILVERY_STRICT] Scroll Tier 1 (buffer shift) activated with sticky children (node: ${props.id ?? node.type}, stickyCount: ${ss.stickyChildren?.length ?? 0})`);
|
|
3470
|
+
const scrollDelta = ss.offset - (ss.prevOffset ?? ss.offset);
|
|
3471
|
+
if (tier === "shift" && clearHeight > 0) {
|
|
3472
|
+
if (props.overflowIndicator === true && !border.top && !border.bottom) {
|
|
3473
|
+
const topIndicatorY = clearY;
|
|
3474
|
+
const bottomIndicatorY = clearY + clearHeight - 1;
|
|
3475
|
+
if (ss.prevOffset != null && ss.prevOffset > 0) buffer.fill(contentX, topIndicatorY, contentWidth, 1, {
|
|
3476
|
+
char: " ",
|
|
3477
|
+
bg: plan.clearBg
|
|
3478
|
+
});
|
|
3479
|
+
buffer.fill(contentX, bottomIndicatorY, contentWidth, 1, {
|
|
3480
|
+
char: " ",
|
|
3481
|
+
bg: plan.clearBg
|
|
3482
|
+
});
|
|
3483
|
+
}
|
|
3484
|
+
buffer.scrollRegion(contentX, clearY, contentWidth, clearHeight, scrollDelta, {
|
|
3485
|
+
char: " ",
|
|
3486
|
+
bg: plan.clearBg
|
|
3487
|
+
});
|
|
3488
|
+
}
|
|
3489
|
+
if (tier === "clear" && clearHeight > 0) buffer.fill(contentX, clearY, contentWidth, clearHeight, {
|
|
3490
|
+
char: " ",
|
|
3491
|
+
bg: plan.clearBg
|
|
3492
|
+
});
|
|
3493
|
+
if (stickyForceRefresh && clearHeight > 0) buffer.fill(contentX, clearY, contentWidth, clearHeight, {
|
|
3494
|
+
char: " ",
|
|
3495
|
+
bg: null
|
|
3496
|
+
});
|
|
3497
|
+
const childAncestorLayoutChanged = isCurrentEpoch(node.layoutChangedThisFrame) || !!ancestorLayoutChanged;
|
|
3498
|
+
const prevVisTop = ss.prevOffset ?? ss.offset;
|
|
3499
|
+
const prevVisBottom = prevVisTop + ss.viewportHeight;
|
|
3500
|
+
for (let i = 0; i < node.children.length; i++) {
|
|
3501
|
+
const child = node.children[i];
|
|
3502
|
+
if (!child) continue;
|
|
3503
|
+
if (child.props.position === "sticky") continue;
|
|
3504
|
+
if (i < ss.firstVisibleChild || i > ss.lastVisibleChild) continue;
|
|
3505
|
+
let thisChildHasPrev = defaultChildHasPrev;
|
|
3506
|
+
let thisChildAncestorCleared = defaultChildAncestorCleared;
|
|
3507
|
+
if (tier === "shift") {
|
|
3508
|
+
const childRect = child.boxRect;
|
|
3509
|
+
if (childRect) {
|
|
3510
|
+
const childTop = childRect.y - layout.y - border.top - padding.top;
|
|
3511
|
+
const childBottom = childTop + childRect.height;
|
|
3512
|
+
const wasFullyVisible = childTop >= prevVisTop && childBottom <= prevVisBottom;
|
|
3513
|
+
thisChildHasPrev = wasFullyVisible;
|
|
3514
|
+
thisChildAncestorCleared = wasFullyVisible ? ancestorCleared || contentRegionCleared : true;
|
|
3515
|
+
}
|
|
3516
|
+
}
|
|
3517
|
+
if (stickyForceRefresh && thisChildHasPrev) {
|
|
3518
|
+
thisChildHasPrev = false;
|
|
3519
|
+
thisChildAncestorCleared = false;
|
|
3520
|
+
}
|
|
3521
|
+
if (canSkipChildSubtree(child, thisChildHasPrev, childAncestorLayoutChanged)) continue;
|
|
3522
|
+
renderNodeToBuffer(child, buffer, {
|
|
3523
|
+
scrollOffset: ss.offset,
|
|
3524
|
+
clipBounds: childClipBounds,
|
|
3525
|
+
hasPrevBuffer: thisChildHasPrev,
|
|
3526
|
+
ancestorCleared: thisChildAncestorCleared,
|
|
3527
|
+
bufferIsCloned,
|
|
3528
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3529
|
+
inheritedBg,
|
|
3530
|
+
inheritedFg
|
|
3531
|
+
}, ctx);
|
|
3532
|
+
}
|
|
3533
|
+
if (ss.stickyChildren) for (const sticky of ss.stickyChildren) {
|
|
3534
|
+
const child = node.children[sticky.index];
|
|
3535
|
+
if (!child?.boxRect) continue;
|
|
3536
|
+
renderNodeToBuffer(child, buffer, {
|
|
3537
|
+
scrollOffset: sticky.naturalTop - sticky.renderOffset,
|
|
3538
|
+
clipBounds: childClipBounds,
|
|
3539
|
+
hasPrevBuffer: false,
|
|
3540
|
+
ancestorCleared: false,
|
|
3541
|
+
bufferIsCloned,
|
|
3542
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3543
|
+
inheritedBg,
|
|
3544
|
+
inheritedFg
|
|
3545
|
+
}, ctx);
|
|
3546
|
+
}
|
|
3547
|
+
}
|
|
3548
|
+
/**
|
|
3549
|
+
* Render children of a normal (non-scroll) container.
|
|
3550
|
+
*/
|
|
3551
|
+
function renderNormalChildren(node, buffer, props, nodeState, childPositionChanged = false, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
|
|
3552
|
+
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg } = nodeState;
|
|
3553
|
+
const instr = resolveInstrumentation(ctx);
|
|
3554
|
+
const layout = node.boxRect;
|
|
3555
|
+
if (!layout) return;
|
|
3556
|
+
const clipX = (props.overflowX ?? props.overflow) === "hidden";
|
|
3557
|
+
const clipY = (props.overflowY ?? props.overflow) === "hidden";
|
|
3558
|
+
const effectiveClipBounds = clipX || clipY ? computeChildClipBounds(layout, props, clipBounds, scrollOffset, clipX, clipY) : clipBounds;
|
|
3559
|
+
const hasStickyChildren = !!(node.stickyChildren && node.stickyChildren.length > 0);
|
|
3560
|
+
const stickyForceRefresh = hasStickyChildren && hasPrevBuffer;
|
|
3561
|
+
if (stickyForceRefresh) {
|
|
3562
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
3563
|
+
top: 0,
|
|
3564
|
+
bottom: 0,
|
|
3565
|
+
left: 0,
|
|
3566
|
+
right: 0
|
|
3567
|
+
};
|
|
3568
|
+
const padding = getPadding(props);
|
|
3569
|
+
let clearX = layout.x + border.left + padding.left;
|
|
3570
|
+
let clearY = layout.y - scrollOffset + border.top + padding.top;
|
|
3571
|
+
let clearW = layout.width - border.left - border.right - padding.left - padding.right;
|
|
3572
|
+
let clearH = layout.height - border.top - border.bottom - padding.top - padding.bottom;
|
|
3573
|
+
if (clipBounds) {
|
|
3574
|
+
const clipTop = clipBounds.top;
|
|
3575
|
+
const clipBottom = clipBounds.bottom;
|
|
3576
|
+
if (clearY < clipTop) {
|
|
3577
|
+
clearH -= clipTop - clearY;
|
|
3578
|
+
clearY = clipTop;
|
|
3579
|
+
}
|
|
3580
|
+
if (clearY + clearH > clipBottom) clearH = clipBottom - clearY;
|
|
3581
|
+
if (clipBounds.left !== void 0 && clearX < clipBounds.left) {
|
|
3582
|
+
clearW -= clipBounds.left - clearX;
|
|
3583
|
+
clearX = clipBounds.left;
|
|
3584
|
+
}
|
|
3585
|
+
if (clipBounds.right !== void 0 && clearX + clearW > clipBounds.right) clearW = clipBounds.right - clearX;
|
|
3586
|
+
}
|
|
3587
|
+
if (clearW > 0 && clearH > 0) buffer.fill(clearX, clearY, clearW, clearH, {
|
|
3588
|
+
char: " ",
|
|
3589
|
+
bg: null
|
|
3590
|
+
});
|
|
3591
|
+
}
|
|
3592
|
+
const childrenNeedRepaint = isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childPositionChanged || childrenNeedFreshRender;
|
|
3593
|
+
if (instr.enabled && childrenNeedRepaint && hasPrevBuffer) {
|
|
3594
|
+
instr.stats.normalChildrenRepaint++;
|
|
3595
|
+
const reasons = [];
|
|
3596
|
+
if (isDirty(node.dirtyBits, node.dirtyEpoch, 8)) reasons.push("childrenDirty");
|
|
3597
|
+
if (childPositionChanged) reasons.push("childPositionChanged");
|
|
3598
|
+
if (childrenNeedFreshRender) reasons.push("childrenNeedFreshRender");
|
|
3599
|
+
instr.stats.normalRepaintReason = reasons.join("+");
|
|
3600
|
+
}
|
|
3601
|
+
let childHasPrev = childrenNeedRepaint ? false : hasPrevBuffer;
|
|
3602
|
+
let childAncestorCleared = contentRegionCleared || ancestorCleared && !getEffectiveBg(props);
|
|
3603
|
+
const childAncestorLayoutChanged = isCurrentEpoch(node.layoutChangedThisFrame) || !!ancestorLayoutChanged;
|
|
3604
|
+
if (stickyForceRefresh) {
|
|
3605
|
+
childHasPrev = false;
|
|
3606
|
+
childAncestorCleared = false;
|
|
3607
|
+
}
|
|
3608
|
+
let hasAbsoluteChildren = false;
|
|
3609
|
+
for (const child of node.children) {
|
|
3610
|
+
const childProps = child.props;
|
|
3611
|
+
if (childProps.position === "absolute") {
|
|
3612
|
+
hasAbsoluteChildren = true;
|
|
3613
|
+
continue;
|
|
3614
|
+
}
|
|
3615
|
+
if (hasStickyChildren && childProps.position === "sticky") continue;
|
|
3616
|
+
if (canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged)) continue;
|
|
3617
|
+
renderNodeToBuffer(child, buffer, {
|
|
3618
|
+
scrollOffset,
|
|
3619
|
+
clipBounds: effectiveClipBounds,
|
|
3620
|
+
hasPrevBuffer: childHasPrev,
|
|
3621
|
+
ancestorCleared: childAncestorCleared,
|
|
3622
|
+
bufferIsCloned,
|
|
3623
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3624
|
+
inheritedBg,
|
|
3625
|
+
inheritedFg
|
|
3626
|
+
}, ctx);
|
|
3627
|
+
}
|
|
3628
|
+
if (node.stickyChildren) for (const sticky of node.stickyChildren) {
|
|
3629
|
+
const child = node.children[sticky.index];
|
|
3630
|
+
if (!child?.boxRect) continue;
|
|
3631
|
+
renderNodeToBuffer(child, buffer, {
|
|
3632
|
+
scrollOffset: sticky.naturalTop - sticky.renderOffset,
|
|
3633
|
+
clipBounds: effectiveClipBounds,
|
|
3634
|
+
hasPrevBuffer: false,
|
|
3635
|
+
ancestorCleared: false,
|
|
3636
|
+
bufferIsCloned,
|
|
3637
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3638
|
+
inheritedBg,
|
|
3639
|
+
inheritedFg
|
|
3640
|
+
}, ctx);
|
|
3641
|
+
}
|
|
3642
|
+
if (hasAbsoluteChildren) for (const child of node.children) {
|
|
3643
|
+
if (child.props.position !== "absolute") continue;
|
|
3644
|
+
renderNodeToBuffer(child, buffer, {
|
|
3645
|
+
scrollOffset,
|
|
3646
|
+
clipBounds: effectiveClipBounds,
|
|
3647
|
+
hasPrevBuffer: false,
|
|
3648
|
+
ancestorCleared: false,
|
|
3649
|
+
bufferIsCloned,
|
|
3650
|
+
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3651
|
+
inheritedBg,
|
|
3652
|
+
inheritedFg
|
|
3653
|
+
}, ctx);
|
|
3654
|
+
}
|
|
3655
|
+
}
|
|
3656
|
+
/**
|
|
3657
|
+
* O(1) pre-check: can we skip calling renderNodeToBuffer() on this child entirely?
|
|
3658
|
+
*
|
|
3659
|
+
* This is the Phase 4 "dirty set rendering" optimization. Instead of calling
|
|
3660
|
+
* renderNodeToBuffer() on every child (which checks canSkipEntireSubtree and
|
|
3661
|
+
* returns early for clean nodes), we skip the function call entirely for
|
|
3662
|
+
* subtrees with no dirty descendants.
|
|
3663
|
+
*
|
|
3664
|
+
* The pre-check is CONSERVATIVE: it only skips when we're certain the subtree
|
|
3665
|
+
* is clean. False negatives (calling renderNodeToBuffer unnecessarily) are
|
|
3666
|
+
* harmless — the existing canSkipEntireSubtree check inside handles them.
|
|
3667
|
+
*
|
|
3668
|
+
* Key insight: subtreeDirtyEpoch is propagated from every dirty node to the
|
|
3669
|
+
* root by markSubtreeDirty (reconciler) and propagateLayout (layout phase).
|
|
3670
|
+
* If subtreeDirtyEpoch !== currentEpoch, no descendant is dirty. Combined
|
|
3671
|
+
* with layoutChangedThisFrame (set by layout phase but NOT included in
|
|
3672
|
+
* subtreeDirtyEpoch on self — only on parent), this covers all dirty paths.
|
|
3673
|
+
*/
|
|
3674
|
+
function canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged) {
|
|
3675
|
+
if (!childHasPrev) return false;
|
|
3676
|
+
if (childAncestorLayoutChanged) return false;
|
|
3677
|
+
if (isDirty(child.dirtyBits, child.dirtyEpoch, 16)) return false;
|
|
3678
|
+
if (isCurrentEpoch(child.layoutChangedThisFrame)) return false;
|
|
3679
|
+
if (child.scrollState && child.scrollState.offset !== child.scrollState.prevOffset) return false;
|
|
3680
|
+
return true;
|
|
3681
|
+
}
|
|
3682
|
+
/**
|
|
3683
|
+
* Clear dirty flags on the current node only (no recursion).
|
|
3684
|
+
* Used after rendering a node to reset its flags.
|
|
3685
|
+
*
|
|
3686
|
+
* With epoch-stamped flags, this is only needed when a subtree is SKIPPED
|
|
3687
|
+
* by the fast path (clearDirtyFlags on skipped subtrees) or for the
|
|
3688
|
+
* render-phase-adapter. The normal render path relies on advanceRenderEpoch()
|
|
3689
|
+
* to expire all flags at once — O(1) instead of O(N).
|
|
3690
|
+
*/
|
|
3691
|
+
function clearNodeDirtyFlags(node) {
|
|
3692
|
+
node.dirtyBits = 0;
|
|
3693
|
+
node.dirtyEpoch = -1;
|
|
3694
|
+
node.layoutChangedThisFrame = -1;
|
|
3695
|
+
}
|
|
3696
|
+
/**
|
|
3697
|
+
* Clear dirty flags on a subtree that was skipped during incremental rendering.
|
|
3698
|
+
*/
|
|
3699
|
+
function clearDirtyFlags(node) {
|
|
3700
|
+
clearNodeDirtyFlags(node);
|
|
3701
|
+
for (const child of node.children) if (child.layoutNode) clearDirtyFlags(child);
|
|
3702
|
+
else clearVirtualTextFlags(child);
|
|
3703
|
+
}
|
|
3704
|
+
/**
|
|
3705
|
+
* Clear dirty flags on a virtual text node and its descendants.
|
|
3706
|
+
* Virtual text nodes (no layoutNode) are rendered by their parent layout
|
|
3707
|
+
* ancestor via collectTextContent(). Their dirty flags must be cleared
|
|
3708
|
+
* after the parent renders, otherwise stale subtreeDirty blocks
|
|
3709
|
+
* markSubtreeDirty() propagation on future updates.
|
|
3710
|
+
*/
|
|
3711
|
+
function clearVirtualTextFlags(node) {
|
|
3712
|
+
clearNodeDirtyFlags(node);
|
|
3713
|
+
for (const child of node.children) clearVirtualTextFlags(child);
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* Check if any child's position changed since last render (sibling shift).
|
|
3717
|
+
* Checked even when subtreeDirty=true because subtreeDirty only means
|
|
3718
|
+
* descendants are dirty, not that this container's gap regions need clearing.
|
|
3719
|
+
*/
|
|
3720
|
+
function hasChildPositionChanged(node) {
|
|
3721
|
+
for (const child of node.children) if (child.boxRect && child.prevLayout) {
|
|
3722
|
+
if (child.boxRect.x !== child.prevLayout.x || child.boxRect.y !== child.prevLayout.y) return true;
|
|
3723
|
+
}
|
|
3724
|
+
return false;
|
|
3725
|
+
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Check if any descendant has an explicit backgroundColor.
|
|
3728
|
+
*
|
|
3729
|
+
* Used by the bgOnlyChange fast path: fillBg() updates ALL cells in the region
|
|
3730
|
+
* with the parent's bg. If a descendant has its own bg, those cells would be
|
|
3731
|
+
* incorrectly overwritten (the descendant is clean and won't re-render to fix it).
|
|
3732
|
+
*
|
|
3733
|
+
* Only checks Box nodes with explicit backgroundColor or effective bg from theme.
|
|
3734
|
+
* Text nodes with backgroundColor are also checked since they render their own bg.
|
|
3735
|
+
* Stops at first match (early exit).
|
|
3736
|
+
*
|
|
3737
|
+
* Performance: walks the child tree, but only runs when bgOnlyChange is true
|
|
3738
|
+
* (bg changed, no other flags). This is the cursor-move hot path where trees
|
|
3739
|
+
* are typically small (card contents: ~5-20 nodes).
|
|
3740
|
+
*/
|
|
3741
|
+
function hasDescendantWithBg(node) {
|
|
3742
|
+
for (const child of node.children) {
|
|
3743
|
+
if (getEffectiveBg(child.props)) return true;
|
|
3744
|
+
if (child.children.length > 0 && hasDescendantWithBg(child)) return true;
|
|
3745
|
+
}
|
|
3746
|
+
return false;
|
|
3747
|
+
}
|
|
3748
|
+
/**
|
|
3749
|
+
* Compute clip bounds for a container's children by insetting for border+padding,
|
|
3750
|
+
* then intersecting with parent clip bounds.
|
|
3751
|
+
*/
|
|
3752
|
+
function computeChildClipBounds(layout, props, parentClip, scrollOffset = 0, horizontal = true, vertical = true) {
|
|
3753
|
+
const border = props.borderStyle ? getBorderSize(props) : {
|
|
3754
|
+
top: 0,
|
|
3755
|
+
bottom: 0,
|
|
3756
|
+
left: 0,
|
|
3757
|
+
right: 0
|
|
3758
|
+
};
|
|
3759
|
+
const padding = getPadding(props);
|
|
3760
|
+
const adjustedY = layout.y - scrollOffset;
|
|
3761
|
+
const nodeClip = vertical ? {
|
|
3762
|
+
top: adjustedY + border.top + padding.top,
|
|
3763
|
+
bottom: adjustedY + layout.height - border.bottom - padding.bottom
|
|
3764
|
+
} : {
|
|
3765
|
+
top: -Infinity,
|
|
3766
|
+
bottom: Infinity
|
|
3767
|
+
};
|
|
3768
|
+
if (horizontal) {
|
|
3769
|
+
nodeClip.left = layout.x + border.left + padding.left;
|
|
3770
|
+
nodeClip.right = layout.x + layout.width - border.right - padding.right;
|
|
3771
|
+
}
|
|
3772
|
+
if (!parentClip) return nodeClip;
|
|
3773
|
+
const result = {
|
|
3774
|
+
top: vertical ? Math.max(parentClip.top, nodeClip.top) : parentClip.top,
|
|
3775
|
+
bottom: vertical ? Math.min(parentClip.bottom, nodeClip.bottom) : parentClip.bottom
|
|
3776
|
+
};
|
|
3777
|
+
if (horizontal && nodeClip.left !== void 0 && nodeClip.right !== void 0) {
|
|
3778
|
+
result.left = Math.max(parentClip.left ?? 0, nodeClip.left);
|
|
3779
|
+
result.right = Math.min(parentClip.right ?? Infinity, nodeClip.right);
|
|
3780
|
+
} else if (parentClip.left !== void 0 && parentClip.right !== void 0) {
|
|
3781
|
+
result.left = parentClip.left;
|
|
3782
|
+
result.right = parentClip.right;
|
|
3783
|
+
}
|
|
3784
|
+
return result;
|
|
3785
|
+
}
|
|
3786
|
+
/**
|
|
3787
|
+
* Clear overflow regions: areas where children's prevLayouts extended beyond
|
|
3788
|
+
* this node's rect. Called when childOverflowChanged detected stale overflow.
|
|
3789
|
+
*
|
|
3790
|
+
* clearNodeRegion handles the node's own rect. This function handles the
|
|
3791
|
+
* overflow area — pixels that a child rendered OUTSIDE the parent's rect
|
|
3792
|
+
* in a previous frame (via overflow:visible behavior). When the child shrinks,
|
|
3793
|
+
* those pixels become stale in the cloned buffer.
|
|
3794
|
+
*
|
|
3795
|
+
* Clears each child's overflow extent, clipped to buffer bounds.
|
|
3796
|
+
*/
|
|
3797
|
+
/**
|
|
3798
|
+
* Clear areas where descendants' previous layouts overflowed beyond THIS node's rect.
|
|
3799
|
+
* Only clears OUTSIDE the node's rect — interior clearing is handled by clearNodeRegion
|
|
3800
|
+
* and renderBox. Recursive: follows subtreeDirty paths to find all overflowing descendants.
|
|
3801
|
+
*/
|
|
3802
|
+
function clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds, threadedInheritedBg) {
|
|
3803
|
+
const clearBg = threadedInheritedBg.color;
|
|
3804
|
+
const nodeRight = layout.x + layout.width;
|
|
3805
|
+
const nodeBottom = layout.y - scrollOffset + layout.height;
|
|
3806
|
+
const nodeLeft = layout.x;
|
|
3807
|
+
const nodeTop = layout.y - scrollOffset;
|
|
3808
|
+
_clearDescendantOverflow(node.children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
|
|
3809
|
+
}
|
|
3810
|
+
function _clearDescendantOverflow(children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg) {
|
|
3811
|
+
for (const child of children) {
|
|
3812
|
+
if (child.prevLayout && isCurrentEpoch(child.layoutChangedThisFrame)) {
|
|
3813
|
+
const prev = child.prevLayout;
|
|
3814
|
+
const prevRight = prev.x + prev.width;
|
|
3815
|
+
const prevBottom = prev.y - scrollOffset + prev.height;
|
|
3816
|
+
const prevTop = prev.y - scrollOffset;
|
|
3817
|
+
if (prevRight > nodeRight) {
|
|
3818
|
+
const overflowX = nodeRight;
|
|
3819
|
+
const overflowWidth = Math.min(prevRight, buffer.width) - overflowX;
|
|
3820
|
+
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
|
|
3821
|
+
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
|
|
3822
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
|
|
3823
|
+
char: " ",
|
|
3824
|
+
bg: clearBg
|
|
3825
|
+
});
|
|
3826
|
+
}
|
|
3827
|
+
if (prevBottom > nodeBottom) {
|
|
3828
|
+
const overflowTop = Math.max(nodeBottom, clipBounds?.top ?? 0);
|
|
3829
|
+
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
|
|
3830
|
+
const overflowX = Math.max(prev.x, clipBounds?.left ?? 0);
|
|
3831
|
+
const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX;
|
|
3832
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
|
|
3833
|
+
char: " ",
|
|
3834
|
+
bg: clearBg
|
|
3835
|
+
});
|
|
3836
|
+
}
|
|
3837
|
+
if (prev.x < nodeLeft) {
|
|
3838
|
+
const overflowX = Math.max(prev.x, 0);
|
|
3839
|
+
const overflowWidth = Math.min(nodeLeft, buffer.width) - overflowX;
|
|
3840
|
+
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
|
|
3841
|
+
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
|
|
3842
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
|
|
3843
|
+
char: " ",
|
|
3844
|
+
bg: clearBg
|
|
3845
|
+
});
|
|
3846
|
+
}
|
|
3847
|
+
if (prevTop < nodeTop) {
|
|
3848
|
+
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
|
|
3849
|
+
const overflowBottom = Math.min(nodeTop, clipBounds?.bottom ?? buffer.height);
|
|
3850
|
+
const overflowX = Math.max(prev.x, clipBounds?.left ?? 0);
|
|
3851
|
+
const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX;
|
|
3852
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) buffer.fill(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, {
|
|
3853
|
+
char: " ",
|
|
3854
|
+
bg: clearBg
|
|
3855
|
+
});
|
|
3856
|
+
}
|
|
3857
|
+
}
|
|
3858
|
+
if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) _clearDescendantOverflow(child.children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
|
|
3859
|
+
}
|
|
3860
|
+
}
|
|
3861
|
+
/**
|
|
3862
|
+
* Clear a node's region with inherited bg when it has no backgroundColor.
|
|
3863
|
+
* Also clears excess area when the node shrank (previous layout was larger).
|
|
3864
|
+
*
|
|
3865
|
+
* Clipping: clips to parent's boxRect (prevents overflow) and to the
|
|
3866
|
+
* colored ancestor's bounds (prevents bg color bleeding into siblings).
|
|
3867
|
+
*/
|
|
3868
|
+
function clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, threadedInheritedBg) {
|
|
3869
|
+
const inherited = threadedInheritedBg;
|
|
3870
|
+
const clearBg = inherited.color;
|
|
3871
|
+
const screenY = layout.y - scrollOffset;
|
|
3872
|
+
const parentRect = node.parent?.boxRect;
|
|
3873
|
+
const parentBottom = parentRect ? parentRect.y - scrollOffset + parentRect.height : void 0;
|
|
3874
|
+
const clearY = clipBounds ? Math.max(screenY, clipBounds.top) : screenY;
|
|
3875
|
+
let clearBottom = clipBounds ? Math.min(screenY + layout.height, clipBounds.bottom) : screenY + layout.height;
|
|
3876
|
+
if (parentBottom !== void 0) clearBottom = Math.min(clearBottom, parentBottom);
|
|
3877
|
+
let clearX = layout.x;
|
|
3878
|
+
let clearWidth = layout.width;
|
|
3879
|
+
if (clipBounds?.left !== void 0 && clipBounds.right !== void 0) {
|
|
3880
|
+
if (clearX < clipBounds.left) {
|
|
3881
|
+
clearWidth -= clipBounds.left - clearX;
|
|
3882
|
+
clearX = clipBounds.left;
|
|
3883
|
+
}
|
|
3884
|
+
if (clearX + clearWidth > clipBounds.right) clearWidth = Math.max(0, clipBounds.right - clearX);
|
|
3885
|
+
}
|
|
3886
|
+
if (inherited.ancestorRect) {
|
|
3887
|
+
const ancestorRight = inherited.ancestorRect.x + inherited.ancestorRect.width;
|
|
3888
|
+
const ancestorLeft = inherited.ancestorRect.x;
|
|
3889
|
+
if (clearX < ancestorLeft) {
|
|
3890
|
+
clearWidth -= ancestorLeft - clearX;
|
|
3891
|
+
clearX = ancestorLeft;
|
|
3892
|
+
}
|
|
3893
|
+
if (clearX + clearWidth > ancestorRight) clearWidth = Math.max(0, ancestorRight - clearX);
|
|
3894
|
+
}
|
|
3895
|
+
const clearHeight = clearBottom - clearY;
|
|
3896
|
+
if (clearHeight > 0 && clearWidth > 0) {
|
|
3897
|
+
const _cellDbg2 = getCellDebug();
|
|
3898
|
+
if (_cellDbg2 && cellCoversPoint(_cellDbg2, clearX, clearY, clearWidth, clearHeight)) {
|
|
3899
|
+
const msg = `CLEAR_REGION ${node.props.id ?? node.type} fill=${clearX},${clearY} ${clearWidth}x${clearHeight} bg=${String(clearBg)} COVERS TARGET`;
|
|
3900
|
+
_cellDbg2.log.push(msg);
|
|
3901
|
+
cellLog.debug?.(msg);
|
|
3902
|
+
}
|
|
3903
|
+
buffer.fill(clearX, clearY, clearWidth, clearHeight, {
|
|
3904
|
+
char: " ",
|
|
3905
|
+
bg: clearBg
|
|
3906
|
+
});
|
|
3907
|
+
}
|
|
3908
|
+
clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, inherited);
|
|
3909
|
+
}
|
|
3910
|
+
/**
|
|
3911
|
+
* Clear the excess area when a node shrinks (old bounds were larger than new).
|
|
3912
|
+
*
|
|
3913
|
+
* This is separated from clearNodeRegion because excess area clearing must happen
|
|
3914
|
+
* even when contentRegionCleared is false. Key scenario: absolute-positioned overlays
|
|
3915
|
+
* (e.g., search dialog) that shrink while normal-flow siblings are dirty. The
|
|
3916
|
+
* forceRepaint path sets hasPrevBuffer=false + ancestorCleared=false, making
|
|
3917
|
+
* contentRegionCleared=false — but the cloned buffer still has stale pixels from
|
|
3918
|
+
* the old larger layout that must be cleared.
|
|
3919
|
+
*
|
|
3920
|
+
* Clips to the COLORED ANCESTOR's content area (not immediate parent's full rect)
|
|
3921
|
+
* to prevent inherited color from bleeding into sibling areas with different bg.
|
|
3922
|
+
*
|
|
3923
|
+
* IMPORTANT: Uses content area (inside border/padding), not full boxRect.
|
|
3924
|
+
* Without this, excess clearing of a child that previously filled the parent's
|
|
3925
|
+
* content area will extend into the parent's border row, overwriting border chars.
|
|
3926
|
+
*/
|
|
3927
|
+
function clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutChanged, inherited) {
|
|
3928
|
+
if (!layoutChanged || !node.prevLayout) return;
|
|
3929
|
+
const prev = node.prevLayout;
|
|
3930
|
+
const _cellDbg3 = getCellDebug();
|
|
3931
|
+
const _prevCoversCell3 = _cellDbg3 && cellCoversPoint(_cellDbg3, prev.x, prev.y - scrollOffset, prev.width, prev.height);
|
|
3932
|
+
if (prev.width <= layout.width && prev.height <= layout.height) {
|
|
3933
|
+
if (_cellDbg3 && _prevCoversCell3) {
|
|
3934
|
+
const msg = `EXCESS_SKIP_NO_SHRINK ${node.props.id ?? node.type} prev=${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height} now=${layout.x},${layout.y - scrollOffset} ${layout.width}x${layout.height}`;
|
|
3935
|
+
_cellDbg3.log.push(msg);
|
|
3936
|
+
cellLog.debug?.(msg);
|
|
3937
|
+
}
|
|
3938
|
+
return;
|
|
3939
|
+
}
|
|
3940
|
+
if (prev.x !== layout.x || prev.y !== layout.y) {
|
|
3941
|
+
if (_cellDbg3 && _prevCoversCell3) {
|
|
3942
|
+
const msg = `EXCESS_SKIP_MOVED ${node.props.id ?? node.type} prev=${prev.x},${prev.y - scrollOffset} ${prev.width}x${prev.height} now=${layout.x},${layout.y - scrollOffset} ${layout.width}x${layout.height} (dx=${layout.x - prev.x} dy=${layout.y - prev.y})`;
|
|
3943
|
+
_cellDbg3.log.push(msg);
|
|
3944
|
+
cellLog.debug?.(msg);
|
|
3945
|
+
}
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3948
|
+
const clearBg = inherited.color;
|
|
3949
|
+
const screenY = layout.y - scrollOffset;
|
|
3950
|
+
const prevScreenY = prev.y - scrollOffset;
|
|
3951
|
+
const clipRect = inherited.ancestorRect ?? node.parent?.boxRect;
|
|
3952
|
+
if (!clipRect) return;
|
|
3953
|
+
let clipRectBottom = clipRect.y - scrollOffset + clipRect.height;
|
|
3954
|
+
let clipRectRight = clipRect.x + clipRect.width;
|
|
3955
|
+
const parent = node.parent;
|
|
3956
|
+
if (parent?.boxRect) {
|
|
3957
|
+
const parentProps = parent.props;
|
|
3958
|
+
const border = getBorderSize(parentProps);
|
|
3959
|
+
const padding = getPadding(parentProps);
|
|
3960
|
+
const parentRight = parent.boxRect.x + parent.boxRect.width - border.right - padding.right;
|
|
3961
|
+
const parentBottom = parent.boxRect.y - scrollOffset + parent.boxRect.height - border.bottom - padding.bottom;
|
|
3962
|
+
clipRectRight = Math.min(clipRectRight, parentRight);
|
|
3963
|
+
clipRectBottom = Math.min(clipRectBottom, parentBottom);
|
|
3964
|
+
}
|
|
3965
|
+
if (prev.width > layout.width) {
|
|
3966
|
+
const excessX = layout.x + layout.width;
|
|
3967
|
+
let excessWidth = prev.width - layout.width;
|
|
3968
|
+
if (excessX + excessWidth > clipRectRight) excessWidth = Math.max(0, clipRectRight - excessX);
|
|
3969
|
+
if (excessWidth > 0) clippedFill(buffer, excessX, excessWidth, prevScreenY, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
|
|
3970
|
+
}
|
|
3971
|
+
if (prev.height > layout.height) {
|
|
3972
|
+
let bottomWidth = prev.width;
|
|
3973
|
+
if (layout.x + bottomWidth > clipRectRight) bottomWidth = Math.max(0, clipRectRight - layout.x);
|
|
3974
|
+
clippedFill(buffer, layout.x, bottomWidth, screenY + layout.height, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
|
|
3975
|
+
}
|
|
3976
|
+
}
|
|
3977
|
+
/** Fill a rectangular region, clipping to clipBounds and an outer bottom limit. */
|
|
3978
|
+
function clippedFill(buffer, x, width, top, bottom, clipBounds, outerBottom, bg) {
|
|
3979
|
+
const clippedTop = clipBounds ? Math.max(top, clipBounds.top) : top;
|
|
3980
|
+
const clippedBottom = Math.min(clipBounds ? Math.min(bottom, clipBounds.bottom) : bottom, outerBottom);
|
|
3981
|
+
let clippedX = x;
|
|
3982
|
+
let clippedWidth = width;
|
|
3983
|
+
if (clipBounds?.left !== void 0 && clipBounds.right !== void 0) {
|
|
3984
|
+
if (clippedX < clipBounds.left) {
|
|
3985
|
+
clippedWidth -= clipBounds.left - clippedX;
|
|
3986
|
+
clippedX = clipBounds.left;
|
|
3987
|
+
}
|
|
3988
|
+
if (clippedX + clippedWidth > clipBounds.right) clippedWidth = Math.max(0, clipBounds.right - clippedX);
|
|
3989
|
+
}
|
|
3990
|
+
const height = clippedBottom - clippedTop;
|
|
3991
|
+
if (height > 0 && clippedWidth > 0) buffer.fill(clippedX, clippedTop, clippedWidth, height, {
|
|
3992
|
+
char: " ",
|
|
3993
|
+
bg
|
|
3994
|
+
});
|
|
3995
|
+
}
|
|
3996
|
+
//#endregion
|
|
3997
|
+
//#region \0@oxc-project+runtime@0.122.0/helpers/usingCtx.js
|
|
3998
|
+
function _usingCtx() {
|
|
3999
|
+
var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
|
|
4000
|
+
var n = Error();
|
|
4001
|
+
return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
|
|
4002
|
+
}, e = {}, n = [];
|
|
4003
|
+
function using(r, e) {
|
|
4004
|
+
if (null != e) {
|
|
4005
|
+
if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
|
|
4006
|
+
if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
|
|
4007
|
+
if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
|
|
4008
|
+
if ("function" != typeof o) throw new TypeError("Object is not disposable.");
|
|
4009
|
+
t && (o = function o() {
|
|
4010
|
+
try {
|
|
4011
|
+
t.call(e);
|
|
4012
|
+
} catch (r) {
|
|
4013
|
+
return Promise.reject(r);
|
|
4014
|
+
}
|
|
4015
|
+
}), n.push({
|
|
4016
|
+
v: e,
|
|
4017
|
+
d: o,
|
|
4018
|
+
a: r
|
|
4019
|
+
});
|
|
4020
|
+
} else r && n.push({
|
|
4021
|
+
d: e,
|
|
4022
|
+
a: r
|
|
4023
|
+
});
|
|
4024
|
+
return e;
|
|
4025
|
+
}
|
|
4026
|
+
return {
|
|
4027
|
+
e,
|
|
4028
|
+
u: using.bind(null, !1),
|
|
4029
|
+
a: using.bind(null, !0),
|
|
4030
|
+
d: function d() {
|
|
4031
|
+
var o, t = this.e, s = 0;
|
|
4032
|
+
function next() {
|
|
4033
|
+
for (; o = n.pop();) try {
|
|
4034
|
+
if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
|
|
4035
|
+
if (o.d) {
|
|
4036
|
+
var r = o.d.call(o.v);
|
|
4037
|
+
if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
|
|
4038
|
+
} else s |= 1;
|
|
4039
|
+
} catch (r) {
|
|
4040
|
+
return err(r);
|
|
4041
|
+
}
|
|
4042
|
+
if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
|
|
4043
|
+
if (t !== e) throw t;
|
|
4044
|
+
}
|
|
4045
|
+
function err(n) {
|
|
4046
|
+
return t = t !== e ? new r(n, t) : n, next();
|
|
4047
|
+
}
|
|
4048
|
+
return next();
|
|
4049
|
+
}
|
|
4050
|
+
};
|
|
4051
|
+
}
|
|
4052
|
+
//#endregion
|
|
4053
|
+
//#region packages/ag-term/src/ag.ts
|
|
4054
|
+
/**
|
|
4055
|
+
* Ag — tree + layout engine + renderer.
|
|
4056
|
+
*
|
|
4057
|
+
* Decomposes the opaque executeRender() into two independent phases:
|
|
4058
|
+
* - ag.layout(dims) — measure + flexbox → positions/sizes
|
|
4059
|
+
* - ag.render() — positioned tree → cell grid → TextFrame
|
|
4060
|
+
*
|
|
4061
|
+
* The output phase (buffer → ANSI) is NOT part of ag — it lives in term.paint().
|
|
4062
|
+
*
|
|
4063
|
+
* @example
|
|
4064
|
+
* ```ts
|
|
4065
|
+
* const ag = createAg(root, { measurer })
|
|
4066
|
+
* ag.layout({ cols: 80, rows: 24 })
|
|
4067
|
+
* const { frame, buffer } = ag.render()
|
|
4068
|
+
* const output = term.paint(buffer, prevBuffer)
|
|
4069
|
+
* ```
|
|
4070
|
+
*/
|
|
4071
|
+
init_buffer();
|
|
4072
|
+
const log$1 = createLogger("silvery:render");
|
|
4073
|
+
const baseLog$1 = createLogger("@silvery/ag-react");
|
|
4074
|
+
function createAg(root, options) {
|
|
4075
|
+
const measurer = options?.measurer;
|
|
4076
|
+
const ctx = measurer ? { measurer } : void 0;
|
|
4077
|
+
let _prevBuffer = null;
|
|
4078
|
+
let hasScroll = false;
|
|
4079
|
+
let hasSticky = false;
|
|
4080
|
+
function doLayout(cols, rows, opts) {
|
|
4081
|
+
try {
|
|
4082
|
+
var _usingCtx$2 = _usingCtx();
|
|
4083
|
+
const prevRootLayout = root.boxRect;
|
|
4084
|
+
if (!(prevRootLayout && (prevRootLayout.width !== cols || prevRootLayout.height !== rows)) && !hasLayoutDirty() && !hasScrollDirty()) {
|
|
4085
|
+
log$1.debug?.("layout: skipped (no layoutDirty, no scrollDirty, dimensions unchanged)");
|
|
4086
|
+
return {
|
|
4087
|
+
tMeasure: 0,
|
|
4088
|
+
tLayout: 0,
|
|
4089
|
+
tScroll: 0,
|
|
4090
|
+
tScrollRect: 0,
|
|
4091
|
+
tNotify: 0
|
|
4092
|
+
};
|
|
4093
|
+
}
|
|
4094
|
+
const render = _usingCtx$2.u(baseLog$1.span("pipeline", {
|
|
4095
|
+
width: cols,
|
|
4096
|
+
height: rows
|
|
4097
|
+
}));
|
|
4098
|
+
let tMeasure;
|
|
4099
|
+
try {
|
|
4100
|
+
var _usingCtx3 = _usingCtx();
|
|
4101
|
+
_usingCtx3.u(render.span("measure"));
|
|
4102
|
+
const t = performance.now();
|
|
4103
|
+
measurePhase(root, ctx);
|
|
4104
|
+
tMeasure = performance.now() - t;
|
|
4105
|
+
log$1.debug?.(`measure: ${tMeasure.toFixed(2)}ms`);
|
|
4106
|
+
} catch (_) {
|
|
4107
|
+
_usingCtx3.e = _;
|
|
4108
|
+
} finally {
|
|
4109
|
+
_usingCtx3.d();
|
|
4110
|
+
}
|
|
4111
|
+
let tLayout;
|
|
4112
|
+
try {
|
|
4113
|
+
var _usingCtx4 = _usingCtx();
|
|
4114
|
+
_usingCtx4.u(render.span("layout"));
|
|
4115
|
+
const t = performance.now();
|
|
4116
|
+
layoutPhase(root, cols, rows);
|
|
4117
|
+
tLayout = performance.now() - t;
|
|
4118
|
+
log$1.debug?.(`layout: ${tLayout.toFixed(2)}ms`);
|
|
4119
|
+
} catch (_) {
|
|
4120
|
+
_usingCtx4.e = _;
|
|
4121
|
+
} finally {
|
|
4122
|
+
_usingCtx4.d();
|
|
4123
|
+
}
|
|
4124
|
+
if (!hasScroll || !hasSticky) {
|
|
4125
|
+
const features = detectPipelineFeatures(root);
|
|
4126
|
+
if (features.hasScroll) hasScroll = true;
|
|
4127
|
+
if (features.hasSticky) hasSticky = true;
|
|
4128
|
+
}
|
|
4129
|
+
let tScroll;
|
|
4130
|
+
if (hasScroll) try {
|
|
4131
|
+
var _usingCtx5 = _usingCtx();
|
|
4132
|
+
_usingCtx5.u(render.span("scroll"));
|
|
4133
|
+
const t = performance.now();
|
|
4134
|
+
scrollPhase(root, { skipStateUpdates: opts?.skipScrollStateUpdates });
|
|
4135
|
+
tScroll = performance.now() - t;
|
|
4136
|
+
} catch (_) {
|
|
4137
|
+
_usingCtx5.e = _;
|
|
4138
|
+
} finally {
|
|
4139
|
+
_usingCtx5.d();
|
|
4140
|
+
}
|
|
4141
|
+
else tScroll = 0;
|
|
4142
|
+
if (hasSticky) stickyPhase(root);
|
|
4143
|
+
let tScrollRect;
|
|
4144
|
+
try {
|
|
4145
|
+
var _usingCtx6 = _usingCtx();
|
|
4146
|
+
_usingCtx6.u(render.span("scrollRect"));
|
|
4147
|
+
const t = performance.now();
|
|
4148
|
+
if (hasScroll || hasSticky) scrollrectPhase(root);
|
|
4149
|
+
else scrollrectPhaseSimple(root);
|
|
4150
|
+
tScrollRect = performance.now() - t;
|
|
4151
|
+
} catch (_) {
|
|
4152
|
+
_usingCtx6.e = _;
|
|
4153
|
+
} finally {
|
|
4154
|
+
_usingCtx6.d();
|
|
4155
|
+
}
|
|
4156
|
+
let tNotify = 0;
|
|
4157
|
+
if (!opts?.skipLayoutNotifications) try {
|
|
4158
|
+
var _usingCtx7 = _usingCtx();
|
|
4159
|
+
_usingCtx7.u(render.span("notify"));
|
|
4160
|
+
const t = performance.now();
|
|
4161
|
+
notifyLayoutSubscribers(root);
|
|
4162
|
+
tNotify = performance.now() - t;
|
|
4163
|
+
} catch (_) {
|
|
4164
|
+
_usingCtx7.e = _;
|
|
4165
|
+
} finally {
|
|
4166
|
+
_usingCtx7.d();
|
|
4167
|
+
}
|
|
4168
|
+
const acc = globalThis.__silvery_bench_phases;
|
|
4169
|
+
if (acc) {
|
|
4170
|
+
acc.measure += tMeasure;
|
|
4171
|
+
acc.layout += tLayout;
|
|
4172
|
+
acc.scroll += tScroll;
|
|
4173
|
+
acc.scrollRect += tScrollRect;
|
|
4174
|
+
acc.notify += tNotify;
|
|
4175
|
+
acc.layoutTotal += tMeasure + tLayout + tScroll + tScrollRect + tNotify;
|
|
4176
|
+
}
|
|
4177
|
+
return {
|
|
4178
|
+
tMeasure,
|
|
4179
|
+
tLayout,
|
|
4180
|
+
tScroll,
|
|
4181
|
+
tScrollRect,
|
|
4182
|
+
tNotify
|
|
4183
|
+
};
|
|
4184
|
+
} catch (_) {
|
|
4185
|
+
_usingCtx$2.e = _;
|
|
4186
|
+
} finally {
|
|
4187
|
+
_usingCtx$2.d();
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
function doRender(opts) {
|
|
4191
|
+
clearBgConflictWarnings();
|
|
4192
|
+
const prevBuffer = opts?.fresh ? null : opts?.prevBuffer !== void 0 ? opts.prevBuffer : _prevBuffer;
|
|
4193
|
+
let tContent;
|
|
4194
|
+
let buffer;
|
|
4195
|
+
{
|
|
4196
|
+
const t = performance.now();
|
|
4197
|
+
buffer = renderPhase(root, prevBuffer, ctx);
|
|
4198
|
+
tContent = performance.now() - t;
|
|
4199
|
+
log$1.debug?.(`content: ${tContent.toFixed(2)}ms`);
|
|
4200
|
+
}
|
|
4201
|
+
if (!opts?.fresh) _prevBuffer = buffer;
|
|
4202
|
+
clearDirtyTracking();
|
|
4203
|
+
const acc = globalThis.__silvery_bench_phases;
|
|
4204
|
+
if (acc) {
|
|
4205
|
+
acc.content += tContent;
|
|
4206
|
+
acc.renderCalls += 1;
|
|
4207
|
+
}
|
|
4208
|
+
return {
|
|
4209
|
+
frame: createTextFrame(buffer),
|
|
4210
|
+
buffer,
|
|
4211
|
+
prevBuffer,
|
|
4212
|
+
tContent
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
function agCreateNode(type, props) {
|
|
4216
|
+
return {
|
|
4217
|
+
type,
|
|
4218
|
+
props,
|
|
4219
|
+
children: [],
|
|
4220
|
+
parent: null,
|
|
4221
|
+
layoutNode: getLayoutEngine().createNode(),
|
|
4222
|
+
boxRect: null,
|
|
4223
|
+
scrollRect: null,
|
|
4224
|
+
screenRect: null,
|
|
4225
|
+
prevLayout: null,
|
|
4226
|
+
prevScrollRect: null,
|
|
4227
|
+
prevScreenRect: null,
|
|
4228
|
+
layoutChangedThisFrame: -1,
|
|
4229
|
+
layoutDirty: true,
|
|
4230
|
+
dirtyBits: 31,
|
|
4231
|
+
dirtyEpoch: getRenderEpoch(),
|
|
4232
|
+
layoutSubscribers: /* @__PURE__ */ new Set()
|
|
4233
|
+
};
|
|
4234
|
+
}
|
|
4235
|
+
function agInsertChild(parent, child, index) {
|
|
4236
|
+
if (child.parent) agRemoveChild(child.parent, child);
|
|
4237
|
+
parent.children.splice(index, 0, child);
|
|
4238
|
+
child.parent = parent;
|
|
4239
|
+
if (parent.layoutNode && child.layoutNode) {
|
|
4240
|
+
const layoutIndex = parent.children.slice(0, index).filter((c) => c.layoutNode !== null).length;
|
|
4241
|
+
parent.layoutNode.insertChild(child.layoutNode, layoutIndex);
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
function agRemoveChild(parent, child) {
|
|
4245
|
+
const index = parent.children.indexOf(child);
|
|
4246
|
+
if (index === -1) return;
|
|
4247
|
+
parent.children.splice(index, 1);
|
|
4248
|
+
if (parent.layoutNode && child.layoutNode) {
|
|
4249
|
+
parent.layoutNode.removeChild(child.layoutNode);
|
|
4250
|
+
child.layoutNode.free();
|
|
4251
|
+
}
|
|
4252
|
+
child.parent = null;
|
|
4253
|
+
}
|
|
4254
|
+
return {
|
|
4255
|
+
root,
|
|
4256
|
+
layout(dims, options) {
|
|
4257
|
+
if (measurer) runWithMeasurer(measurer, () => doLayout(dims.cols, dims.rows, options));
|
|
4258
|
+
else doLayout(dims.cols, dims.rows, options);
|
|
4259
|
+
},
|
|
4260
|
+
render(options) {
|
|
4261
|
+
const result = measurer ? runWithMeasurer(measurer, () => doRender(options)) : doRender(options);
|
|
4262
|
+
return {
|
|
4263
|
+
frame: result.frame,
|
|
4264
|
+
buffer: result.buffer,
|
|
4265
|
+
prevBuffer: result.prevBuffer
|
|
4266
|
+
};
|
|
4267
|
+
},
|
|
4268
|
+
resetBuffer() {
|
|
4269
|
+
_prevBuffer = null;
|
|
4270
|
+
},
|
|
4271
|
+
createNode: agCreateNode,
|
|
4272
|
+
insertChild: agInsertChild,
|
|
4273
|
+
removeChild: agRemoveChild,
|
|
4274
|
+
updateProps(node, props, oldProps) {
|
|
4275
|
+
node.props = props;
|
|
4276
|
+
if (node.layoutNode) node.layoutNode.markDirty();
|
|
4277
|
+
},
|
|
4278
|
+
setText(node, text) {
|
|
4279
|
+
node.textContent = text;
|
|
4280
|
+
const epoch = getRenderEpoch();
|
|
4281
|
+
const bits = 3;
|
|
4282
|
+
node.dirtyBits = node.dirtyEpoch !== epoch ? bits : node.dirtyBits | bits;
|
|
4283
|
+
node.dirtyEpoch = epoch;
|
|
4284
|
+
if (node.layoutNode) node.layoutNode.markDirty();
|
|
4285
|
+
},
|
|
4286
|
+
toString() {
|
|
4287
|
+
return `[Ag root=${root.type} children=${root.children.length}]`;
|
|
4288
|
+
}
|
|
4289
|
+
};
|
|
4290
|
+
}
|
|
4291
|
+
//#endregion
|
|
4292
|
+
//#region packages/ag-term/src/pipeline/index.ts
|
|
4293
|
+
/**
|
|
4294
|
+
* Silvery Render Pipeline
|
|
4295
|
+
*
|
|
4296
|
+
* The 5-phase rendering architecture:
|
|
4297
|
+
*
|
|
4298
|
+
* Phase 0: RECONCILIATION (React)
|
|
4299
|
+
* React reconciliation builds the SilveryNode tree.
|
|
4300
|
+
* Components register layout constraints via props.
|
|
4301
|
+
*
|
|
4302
|
+
* Phase 1: MEASURE (for fit-content nodes)
|
|
4303
|
+
* Traverse nodes with width/height="fit-content"
|
|
4304
|
+
* Measure intrinsic content size
|
|
4305
|
+
* Set Yoga constraints based on measurement
|
|
4306
|
+
*
|
|
4307
|
+
* Phase 2: LAYOUT
|
|
4308
|
+
* Run yoga.calculateLayout()
|
|
4309
|
+
* Propagate computed dimensions to all nodes
|
|
4310
|
+
* Notify useBoxRect() subscribers
|
|
4311
|
+
*
|
|
4312
|
+
* Phase 3: CONTENT RENDER
|
|
4313
|
+
* Render each node to the TerminalBuffer
|
|
4314
|
+
* Handle text truncation, styling, borders
|
|
4315
|
+
*
|
|
4316
|
+
* Phase 4: DIFF & OUTPUT
|
|
4317
|
+
* Compare current buffer with previous
|
|
4318
|
+
* Emit minimal ANSI sequences for changes
|
|
4319
|
+
*/
|
|
4320
|
+
const log = createLogger("silvery:render");
|
|
4321
|
+
createLogger("@silvery/ag-react");
|
|
4322
|
+
/**
|
|
4323
|
+
* Execute the full render pipeline.
|
|
4324
|
+
*
|
|
4325
|
+
* Pass null for prevBuffer on the first render; pass the returned buffer on
|
|
4326
|
+
* subsequent renders to enable incremental content rendering (<1ms vs 20-30ms).
|
|
4327
|
+
* SILVERY_DEV=1 warns at runtime if prevBuffer is null after the first frame.
|
|
4328
|
+
*/
|
|
4329
|
+
function executeRender(root, width, height, prevBuffer, options = "fullscreen", config) {
|
|
4330
|
+
if (config?.measurer) return runWithMeasurer(config.measurer, () => {
|
|
4331
|
+
return executeRenderCore(root, width, height, prevBuffer, options, config);
|
|
4332
|
+
});
|
|
4333
|
+
return executeRenderCore(root, width, height, prevBuffer, options, config);
|
|
4334
|
+
}
|
|
4335
|
+
/** Internal: runs the full pipeline, delegating layout + render to createAg. */
|
|
4336
|
+
function executeRenderCore(root, width, height, prevBuffer, options = "fullscreen", config) {
|
|
4337
|
+
const { mode = "fullscreen", skipLayoutNotifications = false, skipScrollStateUpdates = false, scrollbackOffset = 0, termRows, cursorPos } = typeof options === "string" ? { mode: options } : options;
|
|
4338
|
+
if (process?.env?.SILVERY_DEV && prevBuffer === null && root.prevLayout !== null && !skipLayoutNotifications) log.warn?.("executeRender called with prevBuffer=null on frame 2+ — incremental content rendering is disabled (full render every frame). Track the returned buffer and pass it as prevBuffer on subsequent renders.");
|
|
4339
|
+
const start = performance.now();
|
|
4340
|
+
const ag = createAg(root, { measurer: config?.measurer });
|
|
4341
|
+
ag.layout({
|
|
4342
|
+
cols: width,
|
|
4343
|
+
rows: height
|
|
4344
|
+
}, {
|
|
4345
|
+
skipLayoutNotifications,
|
|
4346
|
+
skipScrollStateUpdates
|
|
4347
|
+
});
|
|
4348
|
+
const { buffer } = ag.render({ prevBuffer });
|
|
4349
|
+
const tLayout = performance.now() - start;
|
|
4350
|
+
let output;
|
|
4351
|
+
let tOutput;
|
|
4352
|
+
{
|
|
4353
|
+
const t4 = performance.now();
|
|
4354
|
+
const outputFn = config?.outputPhaseFn ?? outputPhase;
|
|
4355
|
+
try {
|
|
4356
|
+
output = outputFn(prevBuffer, buffer, mode, scrollbackOffset, termRows, cursorPos);
|
|
4357
|
+
} catch (e) {
|
|
4358
|
+
if (e instanceof Error) e.__silvery_buffer = buffer;
|
|
4359
|
+
throw e;
|
|
4360
|
+
}
|
|
4361
|
+
tOutput = performance.now() - t4;
|
|
4362
|
+
log.debug?.(`output: ${tOutput.toFixed(2)}ms (${output.length} bytes)`);
|
|
4363
|
+
}
|
|
4364
|
+
const total = performance.now() - start;
|
|
4365
|
+
globalThis.__silvery_last_pipeline = {
|
|
4366
|
+
layout: tLayout,
|
|
4367
|
+
output: tOutput,
|
|
4368
|
+
total,
|
|
4369
|
+
incremental: prevBuffer !== null
|
|
4370
|
+
};
|
|
4371
|
+
globalThis.__silvery_render_count = (globalThis.__silvery_render_count ?? 0) + 1;
|
|
4372
|
+
const acc = globalThis.__silvery_bench_phases;
|
|
4373
|
+
if (acc) {
|
|
4374
|
+
acc.output += tOutput;
|
|
4375
|
+
acc.total += total;
|
|
4376
|
+
acc.pipelineCalls += 1;
|
|
4377
|
+
}
|
|
4378
|
+
log.debug?.(`pipeline: layout+render=${tLayout.toFixed(1)}ms output=${tOutput.toFixed(1)}ms total=${total.toFixed(1)}ms`);
|
|
4379
|
+
return {
|
|
4380
|
+
output,
|
|
4381
|
+
buffer
|
|
4382
|
+
};
|
|
4383
|
+
}
|
|
4384
|
+
//#endregion
|
|
4385
|
+
export { signal as i, createAg as n, _usingCtx as r, executeRender as t };
|
|
4386
|
+
|
|
4387
|
+
//# sourceMappingURL=pipeline-DDOPrjuY.mjs.map
|