silvery 0.19.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/Text-Lq0dmj8-.mjs +239 -0
- package/dist/Text-Lq0dmj8-.mjs.map +1 -0
- package/dist/UPNG-Bo33r8rA.mjs +3 -0
- package/dist/UPNG-DosRPdF4.mjs +5075 -0
- package/dist/UPNG-DosRPdF4.mjs.map +1 -0
- package/dist/__vite-browser-external-2447137e-D_JM6skp.mjs +6 -0
- package/dist/__vite-browser-external-2447137e-D_JM6skp.mjs.map +1 -0
- package/dist/{animation-Cn64yepo.mjs → animation-ZMN2_XKv.mjs} +2 -2
- package/dist/animation-ZMN2_XKv.mjs.map +1 -0
- package/dist/{ansi-Cc33mW54.d.mts → ansi-2Xn0yatP.d.mts} +1 -1
- package/dist/{ansi-Cc33mW54.d.mts.map → ansi-2Xn0yatP.d.mts.map} +1 -1
- package/dist/{ansi-CLOitHKx.mjs → ansi-D1KQMAbf.mjs} +1 -1
- package/dist/{ansi-CLOitHKx.mjs.map → ansi-D1KQMAbf.mjs.map} +1 -1
- package/dist/ansi-yC4RyBNY.mjs +22441 -0
- package/dist/ansi-yC4RyBNY.mjs.map +1 -0
- package/dist/apng-CR08rIaH.mjs +58 -0
- package/dist/apng-CR08rIaH.mjs.map +1 -0
- package/dist/apng-DaHfVaVI.mjs +3 -0
- package/dist/assets/resvgjs.darwin-arm64-BtufyGW1.node +0 -0
- package/dist/assets/skia.darwin-arm64-DQs5sT6N.node +0 -0
- package/dist/backend-B-WYLUib.mjs +13396 -0
- package/dist/backend-B-WYLUib.mjs.map +1 -0
- package/dist/backends-CUtan80W.mjs +3 -0
- package/dist/backends-DIVYzKqd.mjs +1083 -0
- package/dist/backends-DIVYzKqd.mjs.map +1 -0
- package/dist/bound-term-0sPrrzH1.d.mts +4640 -0
- package/dist/bound-term-0sPrrzH1.d.mts.map +1 -0
- package/dist/canvas-1v7dPT-_.mjs +3 -0
- package/dist/canvas-CSuPOMNt.mjs +1442 -0
- package/dist/canvas-CSuPOMNt.mjs.map +1 -0
- package/dist/{chunk-Vs_PY4HZ.mjs → chunk-BSw8zbkd.mjs} +1 -1
- package/dist/cli-dvo0r2fs.mjs +4 -0
- package/dist/compare-CQodSH4G.mjs +376 -0
- package/dist/compare-CQodSH4G.mjs.map +1 -0
- package/dist/compare-DHlcxEYA.mjs +3 -0
- package/dist/context-BU5LkkIy.mjs.map +1 -1
- package/dist/devtools-CJdt5H0X.mjs +2 -0
- package/dist/{devtools-DxkSLXDA.mjs → devtools-DcQjgyjL.mjs} +5 -4
- package/dist/{devtools-DxkSLXDA.mjs.map → devtools-DcQjgyjL.mjs.map} +1 -1
- package/dist/easing-BI-ASGMO.d.mts +24 -0
- package/dist/easing-BI-ASGMO.d.mts.map +1 -0
- package/dist/{eta-Bb3RH3wh.mjs → eta-CJlGH06n.mjs} +1 -1
- package/dist/{eta-Bb3RH3wh.mjs.map → eta-CJlGH06n.mjs.map} +1 -1
- package/dist/flexily-zero-adapter-C3Vj0fPt.mjs +306 -0
- package/dist/flexily-zero-adapter-C3Vj0fPt.mjs.map +1 -0
- package/dist/{flexily-zero-adapter-CMxXhdOL.mjs → flexily-zero-adapter-C4lW_Ov5.mjs} +1 -1
- package/dist/fonts-BFmhXDv7.mjs +88 -0
- package/dist/fonts-BFmhXDv7.mjs.map +1 -0
- package/dist/gif-C_AjaT9d.mjs +188 -0
- package/dist/gif-C_AjaT9d.mjs.map +1 -0
- package/dist/gif-DaC4XrxA.mjs +3 -0
- package/dist/gifenc-BOUT-KFB.mjs +730 -0
- package/dist/gifenc-BOUT-KFB.mjs.map +1 -0
- package/dist/image-C2Birh2x.mjs +1252 -0
- package/dist/image-C2Birh2x.mjs.map +1 -0
- package/dist/index-BUMxS65f.d.mts +453 -0
- package/dist/index-BUMxS65f.d.mts.map +1 -0
- package/dist/{index-D3saHouR.d.mts → index-CSQf13CI.d.mts} +1057 -1133
- package/dist/index-CSQf13CI.d.mts.map +1 -0
- package/dist/{index-BXslOebb.d.mts → index-Cl9KKjQ_.d.mts} +4919 -3921
- package/dist/index-Cl9KKjQ_.d.mts.map +1 -0
- package/dist/index-XbNrPhWl.d.mts +336 -0
- package/dist/index-XbNrPhWl.d.mts.map +1 -0
- package/dist/index.d.mts +8 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +14 -12
- package/dist/index.mjs.map +1 -1
- package/dist/key-mapping-CS-YD_cD.mjs +132 -0
- package/dist/key-mapping-CS-YD_cD.mjs.map +1 -0
- package/dist/key-mapping-Yn-Jgrij.mjs +3 -0
- package/dist/{layout-engine-B6Cdz1yZ.mjs → layout-engine-C07LEXWT.mjs} +1 -1
- package/dist/layout-engine-C2px0RJE.mjs +67 -0
- package/dist/layout-engine-C2px0RJE.mjs.map +1 -0
- package/dist/layout-signals-Cnw6xk8Q.mjs +988 -0
- package/dist/layout-signals-Cnw6xk8Q.mjs.map +1 -0
- package/dist/mouse-events-Dki3ISIp.mjs +1044 -0
- package/dist/mouse-events-Dki3ISIp.mjs.map +1 -0
- package/dist/{multi-progress-Bq9Oi_WI.mjs → multi-progress-CIRjrzma.mjs} +3 -3
- package/dist/{multi-progress-Bq9Oi_WI.mjs.map → multi-progress-CIRjrzma.mjs.map} +1 -1
- package/dist/{multi-progress-DAQC7eap.d.mts → multi-progress-DHZ2xUT2.d.mts} +2 -2
- package/dist/{multi-progress-DAQC7eap.d.mts.map → multi-progress-DHZ2xUT2.d.mts.map} +1 -1
- package/dist/{node-BeWlnCPY.mjs → node-CjM5Rt-M.mjs} +4 -4
- package/dist/node-CjM5Rt-M.mjs.map +1 -0
- package/dist/playwright-D5YiZcNS.mjs +76397 -0
- package/dist/playwright-D5YiZcNS.mjs.map +1 -0
- package/dist/png-codec-Dp84742B.mjs +36 -0
- package/dist/png-codec-Dp84742B.mjs.map +1 -0
- package/dist/png-codec-QwOtJ8Zs.mjs +3 -0
- package/dist/progress-DB_Xo071.mjs +675 -0
- package/dist/progress-DB_Xo071.mjs.map +1 -0
- package/dist/{progress-bar-CXE5Qfkd.mjs → progress-bar-oJwq22CR.mjs} +4 -4
- package/dist/{progress-bar-CXE5Qfkd.mjs.map → progress-bar-oJwq22CR.mjs.map} +1 -1
- package/dist/rasterizer-BRXrDdWx.mjs +3 -0
- package/dist/rasterizer-CpEhJvdR.mjs +296 -0
- package/dist/rasterizer-CpEhJvdR.mjs.map +1 -0
- package/dist/reconciler-DldIJB93.mjs +2083 -0
- package/dist/reconciler-DldIJB93.mjs.map +1 -0
- package/dist/{render-string-0mN37DLf.mjs → render-string-BcoCpjCB.mjs} +1 -1
- package/dist/{render-string-X-CxpTdZ.mjs → render-string-DkQacASz.mjs} +2728 -555
- package/dist/render-string-DkQacASz.mjs.map +1 -0
- package/dist/resvg-js-DkOndZI3.mjs +203 -0
- package/dist/resvg-js-DkOndZI3.mjs.map +1 -0
- package/dist/runtime.d.mts +3 -2
- package/dist/runtime.mjs +3 -3
- package/dist/schemes-JjNp4aSl.mjs +2611 -0
- package/dist/schemes-JjNp4aSl.mjs.map +1 -0
- package/dist/{spinner-CGo34vyR.d.mts → spinner-CZINHpkV.d.mts} +2 -2
- package/dist/{spinner-CGo34vyR.d.mts.map → spinner-CZINHpkV.d.mts.map} +1 -1
- package/dist/{spinner-CeOmcuw_.mjs → spinner-D9lrHr8s.mjs} +7 -7
- package/dist/spinner-D9lrHr8s.mjs.map +1 -0
- package/dist/src-5w9QR6_8.mjs +1071 -0
- package/dist/src-5w9QR6_8.mjs.map +1 -0
- package/dist/src-BNTToU7l.mjs +4387 -0
- package/dist/src-BNTToU7l.mjs.map +1 -0
- package/dist/{src-Bd7ezSgG.mjs → src-BR4xNwdG.mjs} +10436 -2622
- package/dist/src-BR4xNwdG.mjs.map +1 -0
- package/dist/{types-Bk2yw9Qj.mjs → src-DKp-_OFG.mjs} +34 -94
- package/dist/src-DKp-_OFG.mjs.map +1 -0
- package/dist/src-bt8wSrfJ.mjs +258 -0
- package/dist/src-bt8wSrfJ.mjs.map +1 -0
- package/dist/src-e33Y6kNJ.mjs +3 -0
- package/dist/src-iDwu25UD.mjs +1814 -0
- package/dist/src-iDwu25UD.mjs.map +1 -0
- package/dist/steps-Bp2uNqnn.d.mts +202 -0
- package/dist/steps-Bp2uNqnn.d.mts.map +1 -0
- package/dist/svg-15lZZzxq.mjs +486 -0
- package/dist/svg-15lZZzxq.mjs.map +1 -0
- package/dist/svg-Cz0UXcDj.mjs +255 -0
- package/dist/svg-Cz0UXcDj.mjs.map +1 -0
- package/dist/svg-DY72a4HK.mjs +3 -0
- package/dist/svg-g1D6ErwR.d.mts +82 -0
- package/dist/svg-g1D6ErwR.d.mts.map +1 -0
- package/dist/term.d.mts +3 -0
- package/dist/term.mjs +9 -0
- package/dist/term.mjs.map +1 -0
- package/dist/theme.d.mts +95 -2
- package/dist/theme.d.mts.map +1 -0
- package/dist/theme.mjs +9 -3
- package/dist/theme.mjs.map +1 -0
- package/dist/{types-BH_v3iMT.d.mts → types-kt_fKR37.d.mts} +2 -15
- package/dist/types-kt_fKR37.d.mts.map +1 -0
- package/dist/ui/animation.d.mts +2 -1
- package/dist/ui/animation.mjs +1 -1
- package/dist/ui/ansi.d.mts +1 -1
- package/dist/ui/ansi.mjs +1 -1
- package/dist/ui/cli.d.mts +3 -3
- package/dist/ui/cli.mjs +5 -5
- package/dist/ui/display.d.mts +1 -1
- package/dist/ui/image.d.mts +2 -2
- package/dist/ui/image.mjs +2 -2
- package/dist/ui/input.d.mts +1 -1
- package/dist/ui/input.mjs +4 -2
- package/dist/ui/input.mjs.map +1 -1
- package/dist/ui/progress.d.mts +5 -249
- package/dist/ui/progress.mjs +5 -858
- package/dist/ui/react.d.mts +1 -1
- package/dist/ui/react.mjs +2 -2
- package/dist/ui/recording-chrome-react.d.mts +21 -0
- package/dist/ui/recording-chrome-react.d.mts.map +1 -0
- package/dist/ui/recording-chrome-react.mjs +105 -0
- package/dist/ui/recording-chrome-react.mjs.map +1 -0
- package/dist/ui/recording-chrome.d.mts +2 -0
- package/dist/ui/recording-chrome.mjs +2 -0
- package/dist/ui/utils.mjs +1 -1
- package/dist/ui/wrappers.d.mts +3 -3
- package/dist/ui/wrappers.mjs +2 -2
- package/dist/ui.d.mts +7 -6
- package/dist/ui.mjs +8 -7
- package/dist/{useLatest-Bg2x4bfP.d.mts → useLatest-DRDDVwjh.d.mts} +5 -25
- package/dist/useLatest-DRDDVwjh.d.mts.map +1 -0
- package/dist/{with-text-input-CRfoiFFG.d.mts → with-text-input-YeohVLeo.d.mts} +4 -55
- package/dist/with-text-input-YeohVLeo.d.mts.map +1 -0
- package/dist/wrapper-C70ATkVv.mjs +3527 -0
- package/dist/wrapper-C70ATkVv.mjs.map +1 -0
- package/dist/{wrappers-UTADQkSY.mjs → wrappers-BCUYITrY.mjs} +5 -157
- package/dist/wrappers-BCUYITrY.mjs.map +1 -0
- package/dist/{yoga-adapter-8oRGRw8V.mjs → yoga-adapter-BnZX1PAY.mjs} +28 -2
- package/dist/yoga-adapter-BnZX1PAY.mjs.map +1 -0
- package/dist/yoga-adapter-DxgsQ_gg.mjs +2 -0
- package/dist/zipBundle-3nqeDRtm.mjs +3 -0
- package/dist/zipBundle-VNAYFmqJ.mjs +2003 -0
- package/dist/zipBundle-VNAYFmqJ.mjs.map +1 -0
- package/package.json +20 -9
- package/dist/animation-Cn64yepo.mjs.map +0 -1
- package/dist/cli-BKp0YtBD.mjs +0 -4
- package/dist/devtools-9QY4teqI.mjs +0 -2
- package/dist/flexily-zero-adapter-BlQa46nr.mjs +0 -3385
- package/dist/flexily-zero-adapter-BlQa46nr.mjs.map +0 -1
- package/dist/image-CTII5QWI.mjs +0 -477
- package/dist/image-CTII5QWI.mjs.map +0 -1
- package/dist/index-BXslOebb.d.mts.map +0 -1
- package/dist/index-BnA7mNpo.d.mts +0 -175
- package/dist/index-BnA7mNpo.d.mts.map +0 -1
- package/dist/index-D3saHouR.d.mts.map +0 -1
- package/dist/layout-engine-ClUgv6jB.mjs +0 -50
- package/dist/layout-engine-ClUgv6jB.mjs.map +0 -1
- package/dist/node-BeWlnCPY.mjs.map +0 -1
- package/dist/reconciler-Cwgm8hRR.mjs +0 -8459
- package/dist/reconciler-Cwgm8hRR.mjs.map +0 -1
- package/dist/render-string-X-CxpTdZ.mjs.map +0 -1
- package/dist/spinner-CeOmcuw_.mjs.map +0 -1
- package/dist/src-B5GjfG7g.mjs +0 -4305
- package/dist/src-B5GjfG7g.mjs.map +0 -1
- package/dist/src-Bd7ezSgG.mjs.map +0 -1
- package/dist/src-CChwjk0Z.mjs +0 -738
- package/dist/src-CChwjk0Z.mjs.map +0 -1
- package/dist/src-NCKb8kE5.mjs +0 -2660
- package/dist/src-NCKb8kE5.mjs.map +0 -1
- package/dist/types-BH_v3iMT.d.mts.map +0 -1
- package/dist/types-Bk2yw9Qj.mjs.map +0 -1
- package/dist/ui/progress.d.mts.map +0 -1
- package/dist/ui/progress.mjs.map +0 -1
- package/dist/useLatest-Bg2x4bfP.d.mts.map +0 -1
- package/dist/with-text-input-CRfoiFFG.d.mts.map +0 -1
- package/dist/wrappers-UTADQkSY.mjs.map +0 -1
- package/dist/yoga-adapter-8oRGRw8V.mjs.map +0 -1
- package/dist/yoga-adapter-D_CcxSt5.mjs +0 -2
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { i as __require } from "./chunk-BSw8zbkd.mjs";
|
|
2
|
+
import { i as signal, n as computed } from "./src-DKp-_OFG.mjs";
|
|
2
3
|
import { c as StdoutContext, l as TermContext, s as StderrContext } from "./context-BU5LkkIy.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
4
|
+
import { a as hasObservedLayoutSignal, c as syncDecorationRects, i as hasLayoutSignals, m as rectEqual, u as syncRectSignals } from "./layout-signals-Cnw6xk8Q.mjs";
|
|
5
|
+
import { _ as isAnyDirty, b as clearDirtyTracking, g as getRenderEpoch, h as advanceRenderEpoch, l as measureStats, o as hostConfig, r as getContainerRoot, t as createContainer, u as collectPlainText, v as isCurrentEpoch, x as hasScrollDirty, y as isDirty } from "./reconciler-DldIJB93.mjs";
|
|
6
|
+
import { _ as cupTo, b as kittyUploadScrimImage, d as monoAttrsForColorString, g as buildScrimPixels, h as backdropPlacementId, m as resolveThemeColor, t as init_src, v as kittyDeleteAllScrimPlacements, y as kittyPlaceAt } from "./src-BNTToU7l.mjs";
|
|
7
|
+
import { t as ansi16DarkTheme } from "./schemes-JjNp4aSl.mjs";
|
|
8
|
+
import { $ as splitGraphemesAnsiAware, A as getActiveLineHeight, Ct as init_buffer, H as isWordBoundary, J as runWithMeasurer, N as graphemeWidth, O as displayWidthAnsi, P as hasAnsi, Q as splitGraphemes, R as isLikelyEmoji, St as createTextFrame, X as sliceByWidthFromEnd, Y as sliceByWidth, _t as bufferToText, gt as bufferToStyledText, ht as ansi256ToRgb, k as ensureEmojiPresentation, mt as TerminalBuffer, n as createTerm, pt as DEFAULT_BG, q as parseAnsiText, rt as wrapText, w as canBreakAnywhere, wt as isDefaultBg, xt as createMutableCell, z as isSoftBreakPoint } from "./ansi-yC4RyBNY.mjs";
|
|
9
|
+
import { t as init_src$1 } from "./src-5w9QR6_8.mjs";
|
|
10
|
+
import { i as isLayoutEngineInitialized, r as getLayoutEngine } from "./layout-engine-C2px0RJE.mjs";
|
|
7
11
|
import React, { act } from "react";
|
|
12
|
+
import { hexToOklch, mixSrgb, oklchToHex, relativeLuminance } from "@silvery/color";
|
|
8
13
|
import { createLogger } from "loggily";
|
|
9
|
-
import * as Upstream from "@silvery/color";
|
|
10
|
-
import { relativeLuminance } from "@silvery/color";
|
|
11
14
|
import Reconciler from "react-reconciler";
|
|
12
15
|
//#region packages/ag-term/src/pipeline/prepared-text.ts
|
|
13
|
-
init_buffer();
|
|
14
16
|
const MAX_FORMAT_ENTRIES = 4;
|
|
15
17
|
/** Content-affecting flags that invalidate plain text. */
|
|
16
18
|
const PLAIN_TEXT_DIRTY = 9;
|
|
@@ -217,6 +219,11 @@ function buildTextAnalysis(text, gWidthFn = graphemeWidth) {
|
|
|
217
219
|
breakIndices.push(i);
|
|
218
220
|
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
219
221
|
currentWordWidth = w;
|
|
222
|
+
} else if (isSoftBreakPoint(g) && i + 1 < len) {
|
|
223
|
+
currentWordWidth += w;
|
|
224
|
+
breakIndices.push(i + 1);
|
|
225
|
+
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
226
|
+
currentWordWidth = 0;
|
|
220
227
|
} else if (w > 0) currentWordWidth += w;
|
|
221
228
|
}
|
|
222
229
|
maxWordWidth = Math.max(maxWordWidth, currentWordWidth);
|
|
@@ -277,11 +284,23 @@ function shrinkwrapWidth(analysis, maxWidth) {
|
|
|
277
284
|
* around forced breaks. Falls back to greedy wrapping for paragraphs
|
|
278
285
|
* where the DP finds no feasible solution (overlong words).
|
|
279
286
|
*
|
|
280
|
-
*
|
|
287
|
+
* `width` accepts either a constant number (uniform width — fast path)
|
|
288
|
+
* or a `WidthFn(lineIndex)` for per-line widths. Per-line uses a
|
|
289
|
+
* forward DP that tracks line index in state.
|
|
290
|
+
*
|
|
291
|
+
* Pass `opts.maxLines` to cap the wrap to ≤ N lines (a 2D DP over
|
|
292
|
+
* candidates × lines-used). When infeasible the function returns `[]`
|
|
293
|
+
* and the caller (`optimalWrap`) is responsible for the truncation
|
|
294
|
+
* fallback.
|
|
295
|
+
*
|
|
296
|
+
* O(breakpoints²) for the constant-width unbounded case;
|
|
297
|
+
* O(breakpoints² × maxLines) for per-line or when `maxLines` is set.
|
|
298
|
+
* All variants are negligible for terminal-scale text.
|
|
281
299
|
*/
|
|
282
|
-
function knuthPlassBreaks(analysis, width) {
|
|
283
|
-
|
|
284
|
-
if (
|
|
300
|
+
function knuthPlassBreaks(analysis, width, opts) {
|
|
301
|
+
const w0 = typeof width === "function" ? width(0) : width;
|
|
302
|
+
if (w0 <= 0) return [];
|
|
303
|
+
if (analysis.totalWidth <= w0 && analysis.newlineIndices.length === 0) return [];
|
|
285
304
|
const { newlineIndices, graphemes } = analysis;
|
|
286
305
|
const allBreaks = [];
|
|
287
306
|
const paragraphStarts = [0];
|
|
@@ -290,14 +309,84 @@ function knuthPlassBreaks(analysis, width) {
|
|
|
290
309
|
const pStart = paragraphStarts[p];
|
|
291
310
|
const pEnd = p + 1 < paragraphStarts.length ? paragraphStarts[p + 1] - 1 : graphemes.length;
|
|
292
311
|
if (pStart >= pEnd) continue;
|
|
293
|
-
const breaks = knuthPlassForParagraph(analysis, pStart, pEnd, width);
|
|
312
|
+
const breaks = typeof width === "function" ? knuthPlassForParagraphPerLine(analysis, pStart, pEnd, width, opts) : knuthPlassForParagraph(analysis, pStart, pEnd, width, opts);
|
|
294
313
|
allBreaks.push(...breaks);
|
|
295
314
|
if (p < paragraphStarts.length - 1 && pEnd < graphemes.length) allBreaks.push(pEnd + 1);
|
|
296
315
|
}
|
|
297
316
|
return allBreaks;
|
|
298
317
|
}
|
|
299
|
-
/**
|
|
300
|
-
|
|
318
|
+
/**
|
|
319
|
+
* Minimum visible-character count for a non-final line before the
|
|
320
|
+
* orphan penalty kicks in. A "token" here is loosely "the visible width
|
|
321
|
+
* of the line"; the penalty is tuned for terminal card widths (≈20-40
|
|
322
|
+
* cols), where any line under 8 cols is visually a stranded fragment.
|
|
323
|
+
*/
|
|
324
|
+
const ORPHAN_MIN_WIDTH = 8;
|
|
325
|
+
/** Penalty (added to leftover²) for a non-final line below ORPHAN_MIN_WIDTH. */
|
|
326
|
+
const ORPHAN_PENALTY = 500;
|
|
327
|
+
/**
|
|
328
|
+
* Threshold for a hyphenated compound to count as "short" on either side.
|
|
329
|
+
* Breaking inside `cmd-hover` (3-5 split) or `auto-` (4 chars left, 9
|
|
330
|
+
* chars right of the hyphen) reads worse than letting the compound
|
|
331
|
+
* stay intact. Penalty is gentler than the orphan penalty because
|
|
332
|
+
* breaking after a hyphen IS sometimes the right call (long compounds,
|
|
333
|
+
* narrow widths).
|
|
334
|
+
*/
|
|
335
|
+
const HYPHEN_COMPOUND_MIN_SIDE = 5;
|
|
336
|
+
/** Penalty (added to leftover²) for a break inside a short hyphenated compound. */
|
|
337
|
+
const HYPHEN_COMPOUND_PENALTY = 250;
|
|
338
|
+
/**
|
|
339
|
+
* Score the *quality* of a non-final wrapped line.
|
|
340
|
+
*
|
|
341
|
+
* Knuth-Plass's classic cost (leftover²) gives the line layout a global
|
|
342
|
+
* raggedness optimum but doesn't know about typographical taboos —
|
|
343
|
+
* single-word "hover" lines, breaks inside short hyphenated compounds
|
|
344
|
+
* like `cmd-hover`, etc. The penalty is added to the squared-leftover
|
|
345
|
+
* cost and tuned so it dominates *only* when the alternative is visually
|
|
346
|
+
* better; if the alternative has worse raggedness, the leftover²
|
|
347
|
+
* differential will still pick the better wrap.
|
|
348
|
+
*
|
|
349
|
+
* Parameters:
|
|
350
|
+
* - `lineStart` — grapheme index where the line begins (a break candidate)
|
|
351
|
+
* - `lineEnd` — grapheme index where the next line begins (a break candidate)
|
|
352
|
+
* - `trimEnd` — `lineEnd` after trailing whitespace/ANSI trim (= visible end)
|
|
353
|
+
*/
|
|
354
|
+
function wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd) {
|
|
355
|
+
let penalty = 0;
|
|
356
|
+
const { cumWidths, graphemes } = analysis;
|
|
357
|
+
if (cumWidths[trimEnd] - cumWidths[lineStart] < ORPHAN_MIN_WIDTH) penalty += ORPHAN_PENALTY;
|
|
358
|
+
if (lineEnd > 0 && graphemes[lineEnd - 1] === "-") {
|
|
359
|
+
let leftLen = 0;
|
|
360
|
+
for (let k = lineEnd - 2; k >= 0; k--) {
|
|
361
|
+
const g = graphemes[k];
|
|
362
|
+
if (g === " " || g === " " || g === "-" || g === "\n") break;
|
|
363
|
+
leftLen++;
|
|
364
|
+
if (leftLen >= HYPHEN_COMPOUND_MIN_SIDE) break;
|
|
365
|
+
}
|
|
366
|
+
let rightLen = 0;
|
|
367
|
+
for (let k = lineEnd; k < graphemes.length; k++) {
|
|
368
|
+
const g = graphemes[k];
|
|
369
|
+
if (g === " " || g === " " || g === "-" || g === "\n") break;
|
|
370
|
+
rightLen++;
|
|
371
|
+
if (rightLen >= HYPHEN_COMPOUND_MIN_SIDE) break;
|
|
372
|
+
}
|
|
373
|
+
if (leftLen < HYPHEN_COMPOUND_MIN_SIDE || rightLen < HYPHEN_COMPOUND_MIN_SIDE) penalty += HYPHEN_COMPOUND_PENALTY;
|
|
374
|
+
}
|
|
375
|
+
return penalty;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* DP for a single paragraph (no newlines). Constant-width.
|
|
379
|
+
*
|
|
380
|
+
* Without `opts.maxLines`: backward 1D DP, `cost[i]` = min cost to reach
|
|
381
|
+
* the end starting at candidate `i`. Final answer = `cost[0]`.
|
|
382
|
+
*
|
|
383
|
+
* With `opts.maxLines`: forward 2D DP, `f[i][k]` = min cost to reach
|
|
384
|
+
* candidate `i` after consuming exactly `k` lines. See
|
|
385
|
+
* `knuthPlassForParagraph2D` below — same shape as the per-line variant
|
|
386
|
+
* but with a constant `width` for every line.
|
|
387
|
+
*/
|
|
388
|
+
function knuthPlassForParagraph(analysis, pStart, pEnd, width, opts) {
|
|
389
|
+
if (opts?.maxLines !== void 0) return knuthPlassForParagraph2D(analysis, pStart, pEnd, () => width, opts.maxLines);
|
|
301
390
|
const { cumWidths, breakIndices, widths, graphemes } = analysis;
|
|
302
391
|
const candidates = [pStart];
|
|
303
392
|
for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
|
|
@@ -311,7 +400,8 @@ function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
|
|
|
311
400
|
const lineStart = candidates[i];
|
|
312
401
|
const lineStartCum = cumWidths[lineStart];
|
|
313
402
|
for (let j = i + 1; j < n; j++) {
|
|
314
|
-
|
|
403
|
+
const lineEnd = candidates[j];
|
|
404
|
+
let trimEnd = lineEnd;
|
|
315
405
|
while (trimEnd > lineStart) {
|
|
316
406
|
const prevG = graphemes[trimEnd - 1];
|
|
317
407
|
if (widths[trimEnd - 1] === 0) {
|
|
@@ -327,7 +417,10 @@ function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
|
|
|
327
417
|
const lineWidth = cumWidths[trimEnd] - lineStartCum;
|
|
328
418
|
if (lineWidth > width) break;
|
|
329
419
|
const leftover = width - lineWidth;
|
|
330
|
-
const
|
|
420
|
+
const isLastLine = j === n - 1;
|
|
421
|
+
let lineCost = isLastLine ? 0 : leftover * leftover;
|
|
422
|
+
if (!isLastLine) lineCost += wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd);
|
|
423
|
+
const totalCost = lineCost + cost[j];
|
|
331
424
|
if (totalCost < cost[i]) {
|
|
332
425
|
cost[i] = totalCost;
|
|
333
426
|
next[i] = j;
|
|
@@ -344,23 +437,216 @@ function knuthPlassForParagraph(analysis, pStart, pEnd, width) {
|
|
|
344
437
|
return breaks;
|
|
345
438
|
}
|
|
346
439
|
/**
|
|
440
|
+
* DP for a single paragraph (no newlines), per-line width.
|
|
441
|
+
*
|
|
442
|
+
* Forward DP — `f[i]` is the min cost to reach candidate i with `line[i]`
|
|
443
|
+
* tracking the line index of position i in the optimal path. Each segment
|
|
444
|
+
* (j → i) is on line `line[j]`, so its allowed width is `widthFn(line[j])`.
|
|
445
|
+
*
|
|
446
|
+
* O(n² × m) where m is max meaningful line index. For titles n is small
|
|
447
|
+
* (~30 candidates, ~5 lines) so this is trivial. The constant-width path
|
|
448
|
+
* above stays as the fast path for callers that don't need per-line.
|
|
449
|
+
*
|
|
450
|
+
* Dispatches to the 2D variant when `opts.maxLines` is set — needed because
|
|
451
|
+
* the optimal path through candidate `i` for line-count `k` may differ from
|
|
452
|
+
* the path for `k+1`, so the single-state `line[]` shortcut is unsafe under
|
|
453
|
+
* a hard line cap.
|
|
454
|
+
*/
|
|
455
|
+
function knuthPlassForParagraphPerLine(analysis, pStart, pEnd, widthFn, opts) {
|
|
456
|
+
if (opts?.maxLines !== void 0) return knuthPlassForParagraph2D(analysis, pStart, pEnd, widthFn, opts.maxLines);
|
|
457
|
+
const { cumWidths, breakIndices, widths, graphemes } = analysis;
|
|
458
|
+
const candidates = [pStart];
|
|
459
|
+
for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
|
|
460
|
+
candidates.push(pEnd);
|
|
461
|
+
const n = candidates.length;
|
|
462
|
+
if (n <= 2) return [];
|
|
463
|
+
const f = new Array(n).fill(Infinity);
|
|
464
|
+
const from = new Array(n).fill(-1);
|
|
465
|
+
const line = new Array(n).fill(0);
|
|
466
|
+
f[0] = 0;
|
|
467
|
+
for (let i = 1; i < n; i++) {
|
|
468
|
+
const lineEnd = candidates[i];
|
|
469
|
+
let trimEnd = lineEnd;
|
|
470
|
+
while (trimEnd > 0) {
|
|
471
|
+
const prevG = graphemes[trimEnd - 1];
|
|
472
|
+
if (widths[trimEnd - 1] === 0) {
|
|
473
|
+
trimEnd--;
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (prevG === " " || prevG === " ") {
|
|
477
|
+
trimEnd--;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
const trimEndCum = cumWidths[trimEnd];
|
|
483
|
+
for (let j = 0; j < i; j++) {
|
|
484
|
+
if (f[j] === Infinity) continue;
|
|
485
|
+
const lineStart = candidates[j];
|
|
486
|
+
const lineWidth = trimEndCum - cumWidths[lineStart];
|
|
487
|
+
const segLineIndex = line[j];
|
|
488
|
+
const allowedWidth = widthFn(segLineIndex);
|
|
489
|
+
if (lineWidth > allowedWidth) continue;
|
|
490
|
+
const leftover = allowedWidth - lineWidth;
|
|
491
|
+
const isLastLine = i === n - 1;
|
|
492
|
+
let lineCost = isLastLine ? 0 : leftover * leftover;
|
|
493
|
+
if (!isLastLine) lineCost += wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd);
|
|
494
|
+
const totalCost = f[j] + lineCost;
|
|
495
|
+
if (totalCost < f[i]) {
|
|
496
|
+
f[i] = totalCost;
|
|
497
|
+
from[i] = j;
|
|
498
|
+
line[i] = segLineIndex + 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
if (f[n - 1] === Infinity) return [];
|
|
503
|
+
const breaks = [];
|
|
504
|
+
let idx = n - 1;
|
|
505
|
+
while (from[idx] > 0) {
|
|
506
|
+
idx = from[idx];
|
|
507
|
+
breaks.push(candidates[idx]);
|
|
508
|
+
}
|
|
509
|
+
breaks.reverse();
|
|
510
|
+
return breaks;
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* DP for a single paragraph with a hard line-budget cap.
|
|
514
|
+
*
|
|
515
|
+
* Forward 2D DP — `f[i][k]` = min cost to reach candidate `i` using
|
|
516
|
+
* exactly `k` lines. Transition:
|
|
517
|
+
*
|
|
518
|
+
* f[i][k+1] = min over j<i of (f[j][k] + lineCost(j → i, line k))
|
|
519
|
+
*
|
|
520
|
+
* The line index `k` is consumed by the (j → i) segment, so its allowed
|
|
521
|
+
* width is `widthFn(k)`. Final answer = `argmin_{k <= maxLines} f[n-1][k]`,
|
|
522
|
+
* with `cost = 0` on the last line (no raggedness penalty on the final
|
|
523
|
+
* line, matching the 1D contracts above).
|
|
524
|
+
*
|
|
525
|
+
* Returns `[]` when no path fits within `maxLines` lines (infeasibility
|
|
526
|
+
* signal — `optimalWrap` falls back to truncation).
|
|
527
|
+
*
|
|
528
|
+
* Complexity: O(n² × maxLines). For terminal titles n ≈ 30 candidates and
|
|
529
|
+
* maxLines ≈ 4-6, so ≤ 5400 ops per paragraph — negligible. Memory: one
|
|
530
|
+
* 2D Float64Array sized `n × (maxLines + 1)`.
|
|
531
|
+
*
|
|
532
|
+
* Tracking: @km/silvery/15130-pretext-maxlines-aware.
|
|
533
|
+
*/
|
|
534
|
+
function knuthPlassForParagraph2D(analysis, pStart, pEnd, widthFn, maxLines) {
|
|
535
|
+
if (maxLines <= 0) return [];
|
|
536
|
+
const { cumWidths, breakIndices, widths, graphemes } = analysis;
|
|
537
|
+
const candidates = [pStart];
|
|
538
|
+
for (const bp of breakIndices) if (bp > pStart && bp <= pEnd) candidates.push(bp);
|
|
539
|
+
candidates.push(pEnd);
|
|
540
|
+
const n = candidates.length;
|
|
541
|
+
if (n <= 2) return [];
|
|
542
|
+
const K = maxLines + 1;
|
|
543
|
+
const f = new Float64Array(n * K).fill(Infinity);
|
|
544
|
+
const from = new Int32Array(n * K).fill(-1);
|
|
545
|
+
f[0] = 0;
|
|
546
|
+
const trimEnds = new Array(n);
|
|
547
|
+
for (let i = 0; i < n; i++) {
|
|
548
|
+
let trimEnd = candidates[i];
|
|
549
|
+
while (trimEnd > 0) {
|
|
550
|
+
const prevG = graphemes[trimEnd - 1];
|
|
551
|
+
if (widths[trimEnd - 1] === 0) {
|
|
552
|
+
trimEnd--;
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
if (prevG === " " || prevG === " ") {
|
|
556
|
+
trimEnd--;
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
trimEnds[i] = trimEnd;
|
|
562
|
+
}
|
|
563
|
+
for (let j = 0; j < n - 1; j++) {
|
|
564
|
+
const lineStart = candidates[j];
|
|
565
|
+
const lineStartCum = cumWidths[lineStart];
|
|
566
|
+
for (let k = 0; k < maxLines; k++) {
|
|
567
|
+
const fjk = f[j * K + k];
|
|
568
|
+
if (fjk === Infinity) continue;
|
|
569
|
+
const allowedWidth = widthFn(k);
|
|
570
|
+
if (allowedWidth <= 0) continue;
|
|
571
|
+
for (let i = j + 1; i < n; i++) {
|
|
572
|
+
const lineEnd = candidates[i];
|
|
573
|
+
const trimEnd = trimEnds[i];
|
|
574
|
+
const lineWidth = cumWidths[trimEnd] - lineStartCum;
|
|
575
|
+
if (lineWidth > allowedWidth) continue;
|
|
576
|
+
const leftover = allowedWidth - lineWidth;
|
|
577
|
+
const isLastLine = i === n - 1;
|
|
578
|
+
let lineCost = isLastLine ? 0 : leftover * leftover;
|
|
579
|
+
if (!isLastLine) lineCost += wrapQualityPenalty(analysis, lineStart, lineEnd, trimEnd);
|
|
580
|
+
const total = fjk + lineCost;
|
|
581
|
+
const target = i * K + (k + 1);
|
|
582
|
+
if (total < f[target]) {
|
|
583
|
+
f[target] = total;
|
|
584
|
+
from[target] = j;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
let bestK = -1;
|
|
590
|
+
let bestCost = Infinity;
|
|
591
|
+
for (let k = 1; k <= maxLines; k++) {
|
|
592
|
+
const c = f[(n - 1) * K + k];
|
|
593
|
+
if (c < bestCost) {
|
|
594
|
+
bestCost = c;
|
|
595
|
+
bestK = k;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
if (bestK === -1 || bestCost === Infinity) return [];
|
|
599
|
+
const breaks = [];
|
|
600
|
+
let curI = n - 1;
|
|
601
|
+
let curK = bestK;
|
|
602
|
+
while (curK > 0) {
|
|
603
|
+
const prev = from[curI * K + curK];
|
|
604
|
+
if (prev < 0) break;
|
|
605
|
+
if (prev > 0) breaks.push(candidates[prev]);
|
|
606
|
+
curI = prev;
|
|
607
|
+
curK = curK - 1;
|
|
608
|
+
}
|
|
609
|
+
breaks.reverse();
|
|
610
|
+
return breaks;
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
347
613
|
* Wrap text using Knuth-Plass optimal breaks.
|
|
348
614
|
* Returns line strings — drop-in replacement for greedy wrap.
|
|
349
615
|
* Falls back to greedy wrapText when DP finds no feasible solution.
|
|
616
|
+
*
|
|
617
|
+
* `width` accepts a constant `number` (uniform width) OR a `WidthFn(lineIndex)`
|
|
618
|
+
* for per-line widths (e.g. line 0 narrowed by a top-right pill, lines 1+
|
|
619
|
+
* full width — CSS-float-equivalent layouts).
|
|
620
|
+
*
|
|
621
|
+
* `opts.maxLines` caps the wrap to ≤ N lines. When the DP can't fit the
|
|
622
|
+
* text in that budget, falls back to a greedy wrap clipped to `maxLines`
|
|
623
|
+
* with the final visible line truncated (head trimmed at a grapheme
|
|
624
|
+
* boundary so `head + truncationSuffix` fits within the budget). Default
|
|
625
|
+
* suffix is `"…"`.
|
|
626
|
+
*
|
|
627
|
+
* Tracking: @km/silvery/15130-pretext-maxlines-aware.
|
|
350
628
|
*/
|
|
351
|
-
function optimalWrap(text, analysis, width) {
|
|
352
|
-
const
|
|
629
|
+
function optimalWrap(text, analysis, width, opts) {
|
|
630
|
+
const maxLines = opts?.maxLines;
|
|
631
|
+
const truncationSuffix = opts?.truncationSuffix ?? "…";
|
|
632
|
+
const breaks = knuthPlassBreaks(analysis, width, maxLines !== void 0 ? { maxLines } : void 0);
|
|
633
|
+
const fallbackWidth = typeof width === "function" ? width(0) : width;
|
|
353
634
|
if (breaks.length === 0) {
|
|
354
|
-
if (analysis.totalWidth <=
|
|
355
|
-
|
|
635
|
+
if (analysis.totalWidth <= fallbackWidth && analysis.newlineIndices.length === 0) return [text];
|
|
636
|
+
const greedy = wrapText(text, fallbackWidth, true, true);
|
|
637
|
+
if (maxLines !== void 0 && greedy.length > maxLines) return clampToMaxLines(greedy, maxLines, fallbackWidth, truncationSuffix);
|
|
638
|
+
return greedy;
|
|
356
639
|
}
|
|
357
640
|
const { graphemes, widths } = analysis;
|
|
358
641
|
const lines = [];
|
|
359
642
|
let lineStart = 0;
|
|
643
|
+
let pendingAnsiPrefix = "";
|
|
360
644
|
for (const bp of breaks) {
|
|
361
645
|
let lineEnd = bp;
|
|
646
|
+
let trailingAnsi = "";
|
|
362
647
|
while (lineEnd > lineStart) {
|
|
363
648
|
if (widths[lineEnd - 1] === 0) {
|
|
649
|
+
trailingAnsi = graphemes[lineEnd - 1] + trailingAnsi;
|
|
364
650
|
lineEnd--;
|
|
365
651
|
continue;
|
|
366
652
|
}
|
|
@@ -371,20 +657,82 @@ function optimalWrap(text, analysis, width) {
|
|
|
371
657
|
}
|
|
372
658
|
break;
|
|
373
659
|
}
|
|
374
|
-
lines.push(graphemes.slice(lineStart, lineEnd).join(""));
|
|
660
|
+
lines.push(pendingAnsiPrefix + graphemes.slice(lineStart, lineEnd).join("") + trailingAnsi);
|
|
661
|
+
pendingAnsiPrefix = "";
|
|
375
662
|
lineStart = bp;
|
|
376
663
|
while (lineStart < graphemes.length) {
|
|
377
664
|
const g = graphemes[lineStart];
|
|
665
|
+
const w = widths[lineStart];
|
|
378
666
|
if (g === " " || g === " ") {
|
|
379
667
|
lineStart++;
|
|
380
668
|
continue;
|
|
381
669
|
}
|
|
670
|
+
if (w === 0) {
|
|
671
|
+
pendingAnsiPrefix += g;
|
|
672
|
+
lineStart++;
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
382
675
|
break;
|
|
383
676
|
}
|
|
384
677
|
}
|
|
385
|
-
if (lineStart < graphemes.length) lines.push(graphemes.slice(lineStart).join(""));
|
|
678
|
+
if (lineStart < graphemes.length) lines.push(pendingAnsiPrefix + graphemes.slice(lineStart).join(""));
|
|
679
|
+
else if (pendingAnsiPrefix !== "") {
|
|
680
|
+
if (lines.length > 0) lines[lines.length - 1] = lines[lines.length - 1] + pendingAnsiPrefix;
|
|
681
|
+
}
|
|
682
|
+
if (maxLines !== void 0 && lines.length > maxLines) return clampToMaxLines(lines, maxLines, fallbackWidth, truncationSuffix);
|
|
386
683
|
return lines;
|
|
387
684
|
}
|
|
685
|
+
/**
|
|
686
|
+
* Clamp a wrapped output to `maxLines` rows. The final visible line is
|
|
687
|
+
* truncated so that `head + suffix` fits within `width`; the head is cut
|
|
688
|
+
* at a grapheme-aware boundary (using `splitGraphemesAnsiAware` to keep
|
|
689
|
+
* ANSI tokens intact).
|
|
690
|
+
*
|
|
691
|
+
* Used by `optimalWrap` when the DP cannot satisfy `opts.maxLines` and
|
|
692
|
+
* falls back to greedy + truncate, AND as a defensive net at the end of
|
|
693
|
+
* `optimalWrap` (the DP already enforces the cap by construction).
|
|
694
|
+
*
|
|
695
|
+
* Tracking: @km/silvery/15130-pretext-maxlines-aware.
|
|
696
|
+
*/
|
|
697
|
+
function clampToMaxLines(lines, maxLines, width, suffix) {
|
|
698
|
+
if (lines.length <= maxLines) return lines;
|
|
699
|
+
if (maxLines <= 0) return [];
|
|
700
|
+
const kept = lines.slice(0, maxLines);
|
|
701
|
+
const lastIdx = maxLines - 1;
|
|
702
|
+
const last = kept[lastIdx];
|
|
703
|
+
kept[lastIdx] = appendTruncationSuffix(last, width, suffix);
|
|
704
|
+
return kept;
|
|
705
|
+
}
|
|
706
|
+
/**
|
|
707
|
+
* Append `suffix` to `line`, trimming graphemes off the visible end if
|
|
708
|
+
* needed so that `displayLength(line + suffix) <= width`. ANSI tokens
|
|
709
|
+
* (zero-width) are preserved across the truncation boundary so styling
|
|
710
|
+
* state isn't dropped.
|
|
711
|
+
*
|
|
712
|
+
* If `suffix` itself is wider than `width`, returns just the suffix
|
|
713
|
+
* (truncated to width via the same grapheme walk). Callers using a
|
|
714
|
+
* pathological width should size-check on their side.
|
|
715
|
+
*/
|
|
716
|
+
function appendTruncationSuffix(line, width, suffix) {
|
|
717
|
+
if (width <= 0) return "";
|
|
718
|
+
const suffixWidth = splitGraphemesAnsiAware(suffix).reduce((acc, g) => acc + graphemeWidth(g), 0);
|
|
719
|
+
const lineGraphemes = splitGraphemesAnsiAware(line);
|
|
720
|
+
if (lineGraphemes.reduce((acc, g) => acc + graphemeWidth(g), 0) + suffixWidth <= width) return line + suffix;
|
|
721
|
+
const budget = Math.max(0, width - suffixWidth);
|
|
722
|
+
let acc = 0;
|
|
723
|
+
let cutoff = 0;
|
|
724
|
+
for (let i = 0; i < lineGraphemes.length; i++) {
|
|
725
|
+
const g = lineGraphemes[i];
|
|
726
|
+
const w = graphemeWidth(g);
|
|
727
|
+
if (acc + w > budget) break;
|
|
728
|
+
acc += w;
|
|
729
|
+
cutoff = i + 1;
|
|
730
|
+
}
|
|
731
|
+
let tail = "";
|
|
732
|
+
for (let i = cutoff; i < lineGraphemes.length; i++) if (graphemeWidth(lineGraphemes[i]) === 0) tail += lineGraphemes[i];
|
|
733
|
+
else break;
|
|
734
|
+
return lineGraphemes.slice(0, cutoff).join("") + tail + suffix;
|
|
735
|
+
}
|
|
388
736
|
//#endregion
|
|
389
737
|
//#region packages/ag-term/src/pipeline/helpers.ts
|
|
390
738
|
/**
|
|
@@ -418,6 +766,363 @@ function getBorderSize(props) {
|
|
|
418
766
|
};
|
|
419
767
|
}
|
|
420
768
|
//#endregion
|
|
769
|
+
//#region packages/ag-term/src/runtime/pass-cause.ts
|
|
770
|
+
/**
|
|
771
|
+
* pass-cause.ts — Renderer feedback-edge instrumentation, loggily-native.
|
|
772
|
+
*
|
|
773
|
+
* Captures the cause of each render/layout pass beyond pass 1. The convergence
|
|
774
|
+
* loop in `renderer.ts` (singlePassLayout / classic) and `create-app.tsx`
|
|
775
|
+
* (processEventBatch flush loop) iterate until `hadReactCommit` is false. Each
|
|
776
|
+
* extra pass is a "feedback edge": some computation in pass N caused React
|
|
777
|
+
* state to change, which forces pass N+1.
|
|
778
|
+
*
|
|
779
|
+
* This module attributes each extra pass to a category (PassCause) and an
|
|
780
|
+
* originating identity (node id, signal name, etc.) so we can answer "what
|
|
781
|
+
* keeps the loop alive?". The data feeds C3b (bounded-convergence) — once we
|
|
782
|
+
* know which feedback edges dominate, we can replace MAX_SINGLE_PASS_ITERATIONS
|
|
783
|
+
* with attributed bounds.
|
|
784
|
+
*
|
|
785
|
+
* ## Design (post dual-pro review, 2026-04-26)
|
|
786
|
+
*
|
|
787
|
+
* Pass-causes are CATEGORICAL events, not duration spans. Both GPT-5.4 Pro
|
|
788
|
+
* and Kimi K2.6 converged on:
|
|
789
|
+
*
|
|
790
|
+
* - Use `log.debug?.("pass", { cause, edge, nodeId, producerPhase })` —
|
|
791
|
+
* not `log.span()`. Spans imply duration; a pass-cause is instantaneous
|
|
792
|
+
* and would log spurious 0ms entries that pollute traces.
|
|
793
|
+
* - Aggregate via a custom pipeline `Stage` that captures `LogEvent.props`.
|
|
794
|
+
* The `Stage` type is part of loggily's public API (`loggily/index.ts`
|
|
795
|
+
* re-exports it from `./pipeline.js`) — it's the framework's intended
|
|
796
|
+
* extension point for non-duration aggregation.
|
|
797
|
+
* - Do NOT use `loggily/metrics`. `MetricsCollector` is intentionally
|
|
798
|
+
* duration-only: `recordSpan(data)` only sees `{name, durationMs}`,
|
|
799
|
+
* props are dropped at the collector boundary, and `summary()` returns
|
|
800
|
+
* `"name: N spans, mean=Xms, p50=Yms"`. For pass-causes the durations
|
|
801
|
+
* would all be ~0ms, and we would lose the edge/node breakdown that
|
|
802
|
+
* C3b consumes.
|
|
803
|
+
* - Emit on a parent + child sub-namespace (`silvery:passes` and
|
|
804
|
+
* `silvery:passes:<cause>`) so `DEBUG=silvery:passes:layout-invalidate`
|
|
805
|
+
* filters by cause without losing the canonical histogram (which reads
|
|
806
|
+
* from the parent-namespace stage).
|
|
807
|
+
* - Default-off via the silvery-side `SILVERY_INSTRUMENT=1` constant-fold
|
|
808
|
+
* gate (zero overhead when unset). Setting it also sets the loggily
|
|
809
|
+
* `LOG_LEVEL=debug` for `silvery:passes` (see logger setup below).
|
|
810
|
+
* - For tests: the aggregator stage is composable; tests can call
|
|
811
|
+
* `createPassCauseAggregator()` for a fresh aggregator and pass
|
|
812
|
+
* `.stage` into a test-local `createLogger("silvery:passes", ...)`.
|
|
813
|
+
*
|
|
814
|
+
* Tracking: km-silvery.renderer-feedback-trace, km-silvery.feedback-trace-loggily
|
|
815
|
+
*/
|
|
816
|
+
/**
|
|
817
|
+
* Module-level instrumentation gate. Constant so JS engines can fold the
|
|
818
|
+
* `if (INSTRUMENT) { ... }` blocks at every emit site out of the hot path.
|
|
819
|
+
*
|
|
820
|
+
* Mapping to loggily:
|
|
821
|
+
* - SILVERY_INSTRUMENT=1 implies LOG_LEVEL=debug + DEBUG=silvery:passes
|
|
822
|
+
* for the silvery:passes namespace, so the loggily pipeline accepts
|
|
823
|
+
* debug-level events (default level is info, which would drop them).
|
|
824
|
+
*
|
|
825
|
+
* The two gates compose:
|
|
826
|
+
* - INSTRUMENT controls the silvery side (emit-call evaluation, prop
|
|
827
|
+
* allocation).
|
|
828
|
+
* - DEBUG/LOG_LEVEL controls the loggily side (which sinks/stages see
|
|
829
|
+
* the event).
|
|
830
|
+
*/
|
|
831
|
+
const INSTRUMENT = process.env.SILVERY_INSTRUMENT === "1";
|
|
832
|
+
const instrumentEnabled = INSTRUMENT;
|
|
833
|
+
function createPassCauseAggregator() {
|
|
834
|
+
let state = {
|
|
835
|
+
records: [],
|
|
836
|
+
perPass: []
|
|
837
|
+
};
|
|
838
|
+
let recordsAtPassStart = 0;
|
|
839
|
+
const stage = (event) => {
|
|
840
|
+
if (event.kind !== "log") return event;
|
|
841
|
+
if (event.namespace !== "silvery:passes" && !event.namespace.startsWith("silvery:passes:")) return event;
|
|
842
|
+
if (event.message !== "pass") return event;
|
|
843
|
+
const props = event.props;
|
|
844
|
+
if (!props?.cause) return event;
|
|
845
|
+
state.records.push({
|
|
846
|
+
cause: props.cause,
|
|
847
|
+
nodeId: props.nodeId,
|
|
848
|
+
edge: props.edge,
|
|
849
|
+
producerPhase: props.producerPhase,
|
|
850
|
+
detail: props.detail
|
|
851
|
+
});
|
|
852
|
+
return event;
|
|
853
|
+
};
|
|
854
|
+
function getHistogram() {
|
|
855
|
+
const byCauseMap = /* @__PURE__ */ new Map();
|
|
856
|
+
for (const r of state.records) {
|
|
857
|
+
let entry = byCauseMap.get(r.cause);
|
|
858
|
+
if (!entry) {
|
|
859
|
+
entry = {
|
|
860
|
+
count: 0,
|
|
861
|
+
edges: /* @__PURE__ */ new Map(),
|
|
862
|
+
nodes: /* @__PURE__ */ new Map()
|
|
863
|
+
};
|
|
864
|
+
byCauseMap.set(r.cause, entry);
|
|
865
|
+
}
|
|
866
|
+
entry.count += 1;
|
|
867
|
+
if (r.edge) entry.edges.set(r.edge, (entry.edges.get(r.edge) ?? 0) + 1);
|
|
868
|
+
if (r.nodeId !== void 0) entry.nodes.set(r.nodeId, (entry.nodes.get(r.nodeId) ?? 0) + 1);
|
|
869
|
+
}
|
|
870
|
+
const byCause = [];
|
|
871
|
+
for (const [cause, entry] of byCauseMap) {
|
|
872
|
+
const topEdges = [...entry.edges.entries()].map(([edge, count]) => ({
|
|
873
|
+
edge,
|
|
874
|
+
count
|
|
875
|
+
})).sort((a, b) => b.count - a.count).slice(0, 8);
|
|
876
|
+
const topNodes = [...entry.nodes.entries()].map(([nodeId, count]) => ({
|
|
877
|
+
nodeId,
|
|
878
|
+
count
|
|
879
|
+
})).sort((a, b) => b.count - a.count).slice(0, 8);
|
|
880
|
+
byCause.push({
|
|
881
|
+
cause,
|
|
882
|
+
count: entry.count,
|
|
883
|
+
topEdges,
|
|
884
|
+
topNodes
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
byCause.sort((a, b) => b.count - a.count);
|
|
888
|
+
return {
|
|
889
|
+
totalRecords: state.records.length,
|
|
890
|
+
perPass: state.perPass.slice(),
|
|
891
|
+
byCause
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
function formatSummary() {
|
|
895
|
+
const h = getHistogram();
|
|
896
|
+
if (h.totalRecords === 0) return "pass-cause histogram: no extra passes recorded";
|
|
897
|
+
const lines = [];
|
|
898
|
+
lines.push(`pass-cause histogram: ${h.totalRecords} extra-pass causes`);
|
|
899
|
+
if (h.perPass.length > 0) {
|
|
900
|
+
const perPassLine = h.perPass.map((c, i) => c > 0 ? `pass${i}=${c}` : null).filter((s) => s !== null).join(" ");
|
|
901
|
+
if (perPassLine) lines.push(` per-pass commits: ${perPassLine}`);
|
|
902
|
+
}
|
|
903
|
+
for (const entry of h.byCause) {
|
|
904
|
+
const pct = (entry.count / h.totalRecords * 100).toFixed(1);
|
|
905
|
+
lines.push(` ${entry.cause}: ${entry.count} (${pct}%)`);
|
|
906
|
+
if (entry.topEdges.length > 0) lines.push(" edges: " + entry.topEdges.map((e) => `${e.edge}×${e.count}`).join(", "));
|
|
907
|
+
if (entry.topNodes.length > 0) lines.push(" nodes: " + entry.topNodes.map((n) => `${n.nodeId}×${n.count}`).join(", "));
|
|
908
|
+
}
|
|
909
|
+
return lines.join("\n");
|
|
910
|
+
}
|
|
911
|
+
function reset() {
|
|
912
|
+
state = {
|
|
913
|
+
records: [],
|
|
914
|
+
perPass: []
|
|
915
|
+
};
|
|
916
|
+
recordsAtPassStart = 0;
|
|
917
|
+
}
|
|
918
|
+
function notePassCommit(passIndex) {
|
|
919
|
+
while (state.perPass.length <= passIndex) state.perPass.push(0);
|
|
920
|
+
state.perPass[passIndex] = (state.perPass[passIndex] ?? 0) + 1;
|
|
921
|
+
if (state.records.length === recordsAtPassStart) state.records.push({
|
|
922
|
+
cause: "unknown",
|
|
923
|
+
detail: `pass-${passIndex}-uncategorized-commit`
|
|
924
|
+
});
|
|
925
|
+
}
|
|
926
|
+
function beginPass(_passIndex) {
|
|
927
|
+
recordsAtPassStart = state.records.length;
|
|
928
|
+
}
|
|
929
|
+
function beginConvergenceLoop() {
|
|
930
|
+
recordsAtPassStart = state.records.length;
|
|
931
|
+
}
|
|
932
|
+
function appendJson(file) {
|
|
933
|
+
const h = getHistogram();
|
|
934
|
+
if (h.totalRecords === 0) return;
|
|
935
|
+
__require("node:fs").appendFileSync(file, JSON.stringify(h) + "\n");
|
|
936
|
+
}
|
|
937
|
+
return {
|
|
938
|
+
stage,
|
|
939
|
+
getHistogram,
|
|
940
|
+
formatSummary,
|
|
941
|
+
reset,
|
|
942
|
+
notePassCommit,
|
|
943
|
+
beginPass,
|
|
944
|
+
beginConvergenceLoop,
|
|
945
|
+
appendJson
|
|
946
|
+
};
|
|
947
|
+
}
|
|
948
|
+
const passAggregator = createPassCauseAggregator();
|
|
949
|
+
/**
|
|
950
|
+
* Pass-cause loggers. Parent namespace is `silvery:passes`. Each cause gets
|
|
951
|
+
* a child namespace `silvery:passes:<cause>` so users can filter at any
|
|
952
|
+
* granularity:
|
|
953
|
+
* DEBUG=silvery:passes → all causes
|
|
954
|
+
* DEBUG=silvery:passes:layout-invalidate → only layout-invalidate
|
|
955
|
+
* DEBUG=silvery:passes:scrollto-settle → only scrollto-settle
|
|
956
|
+
*
|
|
957
|
+
* Both the parent and every child share the same pipeline (level + the
|
|
958
|
+
* aggregator stage). Children are cached on first use to avoid logger
|
|
959
|
+
* construction in the hot path.
|
|
960
|
+
*
|
|
961
|
+
* The aggregator stage is always attached — it's a no-op when no events
|
|
962
|
+
* arrive, and the INSTRUMENT gate at emit sites prevents events when the
|
|
963
|
+
* env var is unset.
|
|
964
|
+
*/
|
|
965
|
+
const passLogConfig = [{ level: instrumentEnabled ? "debug" : "info" }, passAggregator.stage];
|
|
966
|
+
createLogger("silvery:passes", [...passLogConfig]);
|
|
967
|
+
const childLoggers = /* @__PURE__ */ new Map();
|
|
968
|
+
function loggerForCause(cause) {
|
|
969
|
+
let child = childLoggers.get(cause);
|
|
970
|
+
if (!child) {
|
|
971
|
+
child = createLogger(`silvery:passes:${cause}`, [...passLogConfig]);
|
|
972
|
+
childLoggers.set(cause, child);
|
|
973
|
+
}
|
|
974
|
+
return child;
|
|
975
|
+
}
|
|
976
|
+
/**
|
|
977
|
+
* Reset the per-loop pass-index tracker. Called at the top of each
|
|
978
|
+
* convergence loop in renderer.ts / runtime/create-app.tsx.
|
|
979
|
+
*/
|
|
980
|
+
function beginConvergenceLoop() {
|
|
981
|
+
if (!instrumentEnabled) return;
|
|
982
|
+
passAggregator.beginConvergenceLoop();
|
|
983
|
+
}
|
|
984
|
+
/** Mark the start of pass N (0-based). */
|
|
985
|
+
function beginPass(passIndex) {
|
|
986
|
+
if (!instrumentEnabled) return;
|
|
987
|
+
passAggregator.beginPass(passIndex);
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Record that a feedback edge fired during the current pass.
|
|
991
|
+
*
|
|
992
|
+
* Emits on the per-cause child namespace so that
|
|
993
|
+
* `DEBUG=silvery:passes:<cause>` filters by cause; the aggregator stage
|
|
994
|
+
* captures both parent and child events into the canonical histogram.
|
|
995
|
+
*/
|
|
996
|
+
function logPass(record) {
|
|
997
|
+
if (!instrumentEnabled) return;
|
|
998
|
+
loggerForCause(record.cause).debug?.("pass", record);
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Mark that pass N committed React work that will require pass N+1.
|
|
1002
|
+
* Bumps the per-pass-index counter and synthesizes an "unknown" record
|
|
1003
|
+
* if no specific cause was emitted during pass N.
|
|
1004
|
+
*/
|
|
1005
|
+
function notePassCommit(passIndex) {
|
|
1006
|
+
if (!instrumentEnabled) return;
|
|
1007
|
+
passAggregator.notePassCommit(passIndex);
|
|
1008
|
+
}
|
|
1009
|
+
/** Snapshot the current pass-cause histogram. */
|
|
1010
|
+
function getPassHistogram() {
|
|
1011
|
+
if (!instrumentEnabled) return {
|
|
1012
|
+
totalRecords: 0,
|
|
1013
|
+
perPass: [],
|
|
1014
|
+
byCause: []
|
|
1015
|
+
};
|
|
1016
|
+
return passAggregator.getHistogram();
|
|
1017
|
+
}
|
|
1018
|
+
/** Clear all captured records. */
|
|
1019
|
+
function resetPassHistogram() {
|
|
1020
|
+
passAggregator.reset();
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* Print histogram to stderr (test runner / app teardown). No-op if
|
|
1024
|
+
* instrumentation is disabled or no records exist.
|
|
1025
|
+
*
|
|
1026
|
+
* In test environments where stdout/stderr are intercepted, write to
|
|
1027
|
+
* SILVERY_INSTRUMENT_FILE instead.
|
|
1028
|
+
*/
|
|
1029
|
+
function printPassHistogram() {
|
|
1030
|
+
if (!instrumentEnabled) return;
|
|
1031
|
+
if (passAggregator.getHistogram().totalRecords === 0) return;
|
|
1032
|
+
const formatted = passAggregator.formatSummary();
|
|
1033
|
+
const file = process.env.SILVERY_INSTRUMENT_FILE;
|
|
1034
|
+
if (file) {
|
|
1035
|
+
__require("node:fs").appendFileSync(file, "\n" + formatted + "\n");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
process.stderr.write("\n" + formatted + "\n");
|
|
1039
|
+
}
|
|
1040
|
+
/** Append a JSON snapshot to a file (vitest exit hook). */
|
|
1041
|
+
function appendHistogramJson(file) {
|
|
1042
|
+
if (!instrumentEnabled) return;
|
|
1043
|
+
passAggregator.appendJson(file);
|
|
1044
|
+
}
|
|
1045
|
+
/**
|
|
1046
|
+
* Per-cause convergence bound: maximum extra passes (beyond the initial pass)
|
|
1047
|
+
* each PassCause category can structurally trigger before settling. Each
|
|
1048
|
+
* bound is an invariant of how the cause works in the pipeline — not a
|
|
1049
|
+
* histogram observation — and the math is documented in
|
|
1050
|
+
* `hub/silvery/design/convergence-bounds.md`.
|
|
1051
|
+
*
|
|
1052
|
+
* Empirical baseline (C3a v3 corpus, 105 termless app teardowns,
|
|
1053
|
+
* 11 538 records): no test reached pass 1+. The structural ceiling matches:
|
|
1054
|
+
* sum(PASS_CAUSE_BOUNDS) = 0, plus 1 initial pass + 1 settle pass = MAX=2.
|
|
1055
|
+
*
|
|
1056
|
+
* Why every cause's bound is 0:
|
|
1057
|
+
* - layout-invalidate: when a subscriber re-renders, the new layout produces
|
|
1058
|
+
* the SAME rects (idempotent given same input) so the second
|
|
1059
|
+
* notifyLayoutSubscribers reports no change. The settle pass drains the
|
|
1060
|
+
* subscribers' commits; it doesn't seed new feedback.
|
|
1061
|
+
* - intrinsic-shrinkwrap: measurement is content-deterministic; same tree
|
|
1062
|
+
* produces same shrunkWidth / fitContentHeight on the next pass.
|
|
1063
|
+
* - scrollto-settle: prevScrollTo === scrollTo guard prevents the same intent
|
|
1064
|
+
* from re-firing; the recovery edge is a one-shot "target moved offscreen".
|
|
1065
|
+
* - sticky-resettle: stickyChildren is recomputed deterministically from
|
|
1066
|
+
* layout positions — once layout is stable, sticky offsets are stable.
|
|
1067
|
+
* - viewport-resize: dim-change is a depth-0 root trigger, not a feedback
|
|
1068
|
+
* edge. Once the new dims are observed, no further resize fires.
|
|
1069
|
+
* - unknown: any non-zero unknown count is a regression to surface, not
|
|
1070
|
+
* budget to consume.
|
|
1071
|
+
*
|
|
1072
|
+
* The total convergence ceiling = 1 (initial) + 1 (settle) + sum of per-cause
|
|
1073
|
+
* bounds. Per the v3 corpus and the audit above, the per-cause bounds are
|
|
1074
|
+
* ALL 0 — each cause is satisfied within the single settle pass. Total = 2.
|
|
1075
|
+
*/
|
|
1076
|
+
const PASS_CAUSE_BOUNDS = {
|
|
1077
|
+
"layout-invalidate": 0,
|
|
1078
|
+
"intrinsic-shrinkwrap": 0,
|
|
1079
|
+
"scrollto-settle": 0,
|
|
1080
|
+
"sticky-resettle": 0,
|
|
1081
|
+
"viewport-resize": 0,
|
|
1082
|
+
unknown: 0
|
|
1083
|
+
};
|
|
1084
|
+
/**
|
|
1085
|
+
* Total subscriber-feedback bound: 1 (initial) + 1 (one settle pass) + sum
|
|
1086
|
+
* of PASS_CAUSE_BOUNDS = 2. This applies to loops where pipeline output and
|
|
1087
|
+
* React effects flush in **separate phases** — singlePassLayout (one
|
|
1088
|
+
* runPipeline + a separate effect-flush loop), the effect-flush loop
|
|
1089
|
+
* itself, and the production processEventBatch flush.
|
|
1090
|
+
*
|
|
1091
|
+
* The `classic` loop in renderer.ts is structurally different: it runs
|
|
1092
|
+
* runPipeline + flushSyncWork **interleaved** within the same loop, so a
|
|
1093
|
+
* single iteration may emit a rect-signal change AND drain a virtualizer
|
|
1094
|
+
* re-render that mounts new items, which then need ANOTHER iteration to
|
|
1095
|
+
* lay out. That's not subscriber feedback — it's layout-vs-React
|
|
1096
|
+
* stabilisation, and the historical empirical bound for it is 5. Tests
|
|
1097
|
+
* that exercise virtualizer + scroll convergence (e.g.
|
|
1098
|
+
* list-view-refinements with 100 items + indexed virtualization) actually
|
|
1099
|
+
* use 3-4 of those iterations to settle the rendered window.
|
|
1100
|
+
*
|
|
1101
|
+
* Both bounds replace the four prior magic constants
|
|
1102
|
+
* (MAX_SINGLE_PASS_ITERATIONS=15, MAX_LAYOUT_ITERATIONS=5,
|
|
1103
|
+
* MAX_EFFECT_FLUSHES=5, maxFlushes=5) — the 15-pass cap is gone, and the
|
|
1104
|
+
* 5 is now an explicit per-loop documented value, not a guess.
|
|
1105
|
+
*/
|
|
1106
|
+
const MAX_CONVERGENCE_PASSES = 2 + PASS_CAUSE_BOUNDS["layout-invalidate"] + PASS_CAUSE_BOUNDS["intrinsic-shrinkwrap"] + PASS_CAUSE_BOUNDS["scrollto-settle"] + PASS_CAUSE_BOUNDS["sticky-resettle"] + PASS_CAUSE_BOUNDS["viewport-resize"] + PASS_CAUSE_BOUNDS.unknown;
|
|
1107
|
+
/**
|
|
1108
|
+
* Assert the convergence loop did not exceed its cap. Called from the
|
|
1109
|
+
* renderer's loops + create-app's processEventBatch flush loop. No-op
|
|
1110
|
+
* outside SILVERY_STRICT — the loop bound itself is the production
|
|
1111
|
+
* safety net.
|
|
1112
|
+
*
|
|
1113
|
+
* STRICT=2: throws with a per-cause breakdown so a regression names the
|
|
1114
|
+
* responsible edge instead of just "exhausted N iterations".
|
|
1115
|
+
* STRICT=1: emits a stderr warning with the same breakdown.
|
|
1116
|
+
*/
|
|
1117
|
+
function assertBoundedConvergence(passCount, loopName, cap) {
|
|
1118
|
+
if (passCount <= cap) return;
|
|
1119
|
+
const strict = process?.env?.SILVERY_STRICT;
|
|
1120
|
+
if (!strict) return;
|
|
1121
|
+
const msg = `convergence bound exceeded in ${loopName}: ${passCount} passes ran but cap=${cap} (default MAX_CONVERGENCE_PASSES=${MAX_CONVERGENCE_PASSES}). Per-cause breakdown: ${getPassHistogram().byCause.map((e) => `${e.cause}=${e.count}(bound=${PASS_CAUSE_BOUNDS[e.cause]})`).join(", ") || "(no records — INSTRUMENT off)"}. Either a feedback edge broke its per-cause invariant, or a new edge needs a PassCause category in pass-cause.ts.`;
|
|
1122
|
+
if (strict === "2") throw new Error(msg);
|
|
1123
|
+
process.stderr.write(`[silvery] ${msg}\n`);
|
|
1124
|
+
}
|
|
1125
|
+
//#endregion
|
|
421
1126
|
//#region packages/ag-term/src/pipeline/measure-phase.ts
|
|
422
1127
|
/**
|
|
423
1128
|
* Handle fit-content nodes by measuring their intrinsic content size.
|
|
@@ -446,11 +1151,25 @@ function measurePhase(root, ctx) {
|
|
|
446
1151
|
}
|
|
447
1152
|
if (isSnugContent) {
|
|
448
1153
|
const shrunkWidth = computeSnugContentWidth(node, measureIntrinsicSize(node, ctx, availableWidth).width, ctx);
|
|
1154
|
+
const prevWidth = node.boxRect?.width;
|
|
449
1155
|
node.layoutNode.setMaxWidth(shrunkWidth);
|
|
1156
|
+
if (INSTRUMENT && prevWidth !== void 0 && prevWidth !== shrunkWidth) logPass({
|
|
1157
|
+
cause: "intrinsic-shrinkwrap",
|
|
1158
|
+
edge: "snug-content:width",
|
|
1159
|
+
producerPhase: "measure",
|
|
1160
|
+
detail: `${prevWidth}→${shrunkWidth}`
|
|
1161
|
+
});
|
|
450
1162
|
}
|
|
451
1163
|
if (isHeightFitContent) {
|
|
452
1164
|
const intrinsicSize = measureIntrinsicSize(node, ctx, availableWidth);
|
|
1165
|
+
const prevHeight = node.boxRect?.height;
|
|
453
1166
|
node.layoutNode.setHeight(intrinsicSize.height);
|
|
1167
|
+
if (INSTRUMENT && prevHeight !== void 0 && prevHeight !== intrinsicSize.height) logPass({
|
|
1168
|
+
cause: "intrinsic-shrinkwrap",
|
|
1169
|
+
edge: "fit-content:height",
|
|
1170
|
+
producerPhase: "measure",
|
|
1171
|
+
detail: `${prevHeight}→${intrinsicSize.height}`
|
|
1172
|
+
});
|
|
454
1173
|
}
|
|
455
1174
|
}
|
|
456
1175
|
});
|
|
@@ -482,8 +1201,10 @@ function measureIntrinsicSize(node, ctx, availableWidth) {
|
|
|
482
1201
|
}
|
|
483
1202
|
const transform = textProps.internal_transform;
|
|
484
1203
|
let lines;
|
|
485
|
-
if (availableWidth !== void 0 && availableWidth > 0 && isWrapEnabled(textProps.wrap))
|
|
486
|
-
|
|
1204
|
+
if (availableWidth !== void 0 && availableWidth > 0 && isWrapEnabled(textProps.wrap)) {
|
|
1205
|
+
const truncateAtomic = textProps.wrap === "wrap-truncate";
|
|
1206
|
+
lines = ctx ? ctx.measurer.wrapText(text, availableWidth, true, true, truncateAtomic) : wrapText(text, availableWidth, true, true, truncateAtomic);
|
|
1207
|
+
} else lines = text.split("\n");
|
|
487
1208
|
if (transform) lines = lines.map((line, index) => transform(line, index));
|
|
488
1209
|
return {
|
|
489
1210
|
width: Math.max(...lines.map((line) => getTextWidth$1(line, ctx))),
|
|
@@ -528,7 +1249,7 @@ function measureIntrinsicSize(node, ctx, availableWidth) {
|
|
|
528
1249
|
* Check if text wrapping is enabled for a text node.
|
|
529
1250
|
*/
|
|
530
1251
|
function isWrapEnabled(wrap) {
|
|
531
|
-
return wrap === "wrap" || wrap === "hard" || wrap === "even" || wrap === true || wrap === void 0;
|
|
1252
|
+
return wrap === "wrap" || wrap === "wrap-truncate" || wrap === "hard" || wrap === "even" || wrap === true || wrap === void 0;
|
|
532
1253
|
}
|
|
533
1254
|
/**
|
|
534
1255
|
* Compute snug-content width for a node.
|
|
@@ -612,6 +1333,49 @@ function getTextWidth$1(text, ctx) {
|
|
|
612
1333
|
*/
|
|
613
1334
|
const log$2 = createLogger("silvery:layout");
|
|
614
1335
|
/**
|
|
1336
|
+
* Stable-ish identity string for an AgNode used by pass-cause records.
|
|
1337
|
+
* Prefers explicit identity props (testid / id / name / nodeId) and falls
|
|
1338
|
+
* back to type. Keeps the histogram readable without paying a Map lookup
|
|
1339
|
+
* per node when SILVERY_INSTRUMENT is unset (call site is gated by the
|
|
1340
|
+
* inert no-op in `logPass`).
|
|
1341
|
+
*/
|
|
1342
|
+
function nodeIdent(node) {
|
|
1343
|
+
const props = node.props;
|
|
1344
|
+
const ident = props?.["testid"] ?? props?.["id"] ?? props?.["name"] ?? props?.["nodeId"];
|
|
1345
|
+
return ident ? `${node.type}#${ident}` : node.type;
|
|
1346
|
+
}
|
|
1347
|
+
function nodePath(node) {
|
|
1348
|
+
const path = [];
|
|
1349
|
+
let current = node;
|
|
1350
|
+
while (current) {
|
|
1351
|
+
path.push(nodeIdent(current));
|
|
1352
|
+
current = current.parent;
|
|
1353
|
+
}
|
|
1354
|
+
return path.reverse().join(" > ");
|
|
1355
|
+
}
|
|
1356
|
+
function layoutPropsForLog(node) {
|
|
1357
|
+
const props = node.props ?? {};
|
|
1358
|
+
return {
|
|
1359
|
+
width: props.width,
|
|
1360
|
+
maxWidth: props.maxWidth,
|
|
1361
|
+
minWidth: props.minWidth,
|
|
1362
|
+
flexGrow: props.flexGrow,
|
|
1363
|
+
flexShrink: props.flexShrink,
|
|
1364
|
+
flexBasis: props.flexBasis,
|
|
1365
|
+
alignSelf: props.alignSelf,
|
|
1366
|
+
position: props.position,
|
|
1367
|
+
overflow: props.overflow,
|
|
1368
|
+
borderStyle: props.borderStyle,
|
|
1369
|
+
padding: props.padding,
|
|
1370
|
+
paddingLeft: props.paddingLeft,
|
|
1371
|
+
paddingRight: props.paddingRight
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
function normalizeScrollOffset(offset) {
|
|
1375
|
+
if (!Number.isFinite(offset)) return 0;
|
|
1376
|
+
return Math.round(offset);
|
|
1377
|
+
}
|
|
1378
|
+
/**
|
|
615
1379
|
* Run Yoga layout calculation and propagate dimensions to all nodes.
|
|
616
1380
|
*
|
|
617
1381
|
* @param root The root SilveryNode
|
|
@@ -765,8 +1529,72 @@ function _hasChildPositionChanged(node) {
|
|
|
765
1529
|
return false;
|
|
766
1530
|
}
|
|
767
1531
|
/**
|
|
768
|
-
*
|
|
769
|
-
*
|
|
1532
|
+
* Lightweight `getEffectiveBg` for the layout phase. Mirrors the
|
|
1533
|
+
* production helper in `render-box.ts` (Sterling-aware: `bg-surface-default`
|
|
1534
|
+
* → legacy `bg`). Inlined here to avoid a layout→render-box import cycle.
|
|
1535
|
+
*
|
|
1536
|
+
* The render phase still uses its own `getEffectiveBg` for the cascade
|
|
1537
|
+
* formulas; this duplicate exists ONLY so the layout phase can flag
|
|
1538
|
+
* "bg-bearing descendant moved/shrank" without dragging render-box into
|
|
1539
|
+
* the layout module graph.
|
|
1540
|
+
*/
|
|
1541
|
+
function _layoutGetEffectiveBg(props) {
|
|
1542
|
+
if (props.backgroundColor) return props.backgroundColor;
|
|
1543
|
+
const theme = props.theme;
|
|
1544
|
+
if (theme) {
|
|
1545
|
+
const sterlingBg = theme["bg-surface-default"];
|
|
1546
|
+
if (typeof sterlingBg === "string") return sterlingBg;
|
|
1547
|
+
const legacyBg = theme["bg"];
|
|
1548
|
+
if (typeof legacyBg === "string") return legacyBg;
|
|
1549
|
+
}
|
|
1550
|
+
}
|
|
1551
|
+
/**
|
|
1552
|
+
* True iff `prev` has any cells NOT covered by `cur` (shrink, move, or any
|
|
1553
|
+
* combination thereof). This is the "bg residue" predicate: cells the
|
|
1554
|
+
* painter occupied before but doesn't occupy now. Equivalent to
|
|
1555
|
+
* `prev \ cur ≠ ∅`.
|
|
1556
|
+
*/
|
|
1557
|
+
function _prevHasResidueOutside(prev, cur) {
|
|
1558
|
+
return prev.x < cur.x || prev.y < cur.y || prev.x + prev.width > cur.x + cur.width || prev.y + prev.height > cur.y + cur.height;
|
|
1559
|
+
}
|
|
1560
|
+
/**
|
|
1561
|
+
* Check if any descendant was overflowing THIS node's rect and had its layout
|
|
1562
|
+
* change OR if any bg-bearing descendant shrank/moved within this node's rect
|
|
1563
|
+
* (leaving stale bg residue inside).
|
|
1564
|
+
*
|
|
1565
|
+
* Two related cases:
|
|
1566
|
+
*
|
|
1567
|
+
* 1. **Outside-overflow** (original): descendant's prev rect extended
|
|
1568
|
+
* BEYOND `node`'s current rect. The descendant either grew past
|
|
1569
|
+
* `node`'s edge before and shrank back inside, or moved out and back —
|
|
1570
|
+
* either way `node`'s border/padding cells outside the descendant's
|
|
1571
|
+
* new rect carry stale pixels.
|
|
1572
|
+
*
|
|
1573
|
+
* 2. **Inside-bg-residue** (added 2026-05-07, bead
|
|
1574
|
+
* @km/silvery/incremental-bg-residue-shrink-move): descendant has
|
|
1575
|
+
* `effectiveBg` AND its prev rect has cells NOT covered by its
|
|
1576
|
+
* current rect (any shrink/move). The painter's prev paint covered
|
|
1577
|
+
* those cells with its bg; on this frame nothing in the painter's
|
|
1578
|
+
* subtree paints them. If those cells are within `node`'s rect,
|
|
1579
|
+
* `node` must clear them via its own `clearNodeRegion` (when
|
|
1580
|
+
* transparent) or `renderBox` fill (when bg-bearing). Without this
|
|
1581
|
+
* flag, `node`'s contentAreaAffected may be FALSE — the painter's
|
|
1582
|
+
* `clearExcessArea` SKIPS due to the position-change guard, and
|
|
1583
|
+
* `node`'s fast-path-with-subtreeDirty doesn't trigger any cleanup.
|
|
1584
|
+
* The cell carries forward bg from the prev frame indefinitely.
|
|
1585
|
+
*
|
|
1586
|
+
* Why on `node` and not just the immediate parent: the residue
|
|
1587
|
+
* cells must be covered by SOME ancestor that clears or fills.
|
|
1588
|
+
* The closest transparent ancestor that contains the residue cells
|
|
1589
|
+
* must clear them; if every ancestor up to a bg-bearing one is
|
|
1590
|
+
* transparent, that bg-bearing ancestor's renderBox fill suffices.
|
|
1591
|
+
* Setting the flag at every ancestor whose rect contains residue
|
|
1592
|
+
* cells guarantees coverage regardless of where the bg-break is.
|
|
1593
|
+
*
|
|
1594
|
+
* Recursive: follows subtreeDirty paths for efficiency. Returns early on
|
|
1595
|
+
* first match. Performance: only runs when subtreeDirty (matches the
|
|
1596
|
+
* `_hasAbsoluteChildMutated` guard); for typical UI updates the search
|
|
1597
|
+
* touches < 10 nodes before terminating.
|
|
770
1598
|
*/
|
|
771
1599
|
function _hasDescendantOverflowChanged(node, rect) {
|
|
772
1600
|
return _checkDescendantOverflow(node.children, rect.x, rect.y, rect.x + rect.width, rect.y + rect.height);
|
|
@@ -776,6 +1604,16 @@ function _checkDescendantOverflow(children, nodeLeft, nodeTop, nodeRight, nodeBo
|
|
|
776
1604
|
if (child.prevLayout && isCurrentEpoch(child.layoutChangedThisFrame)) {
|
|
777
1605
|
const prev = child.prevLayout;
|
|
778
1606
|
if (prev.x + prev.width > nodeRight || prev.y + prev.height > nodeBottom || prev.x < nodeLeft || prev.y < nodeTop) return true;
|
|
1607
|
+
if (child.boxRect) {
|
|
1608
|
+
const props = child.props;
|
|
1609
|
+
if (_layoutGetEffectiveBg(props) !== void 0 && _prevHasResidueOutside(prev, child.boxRect)) {
|
|
1610
|
+
const ix1 = Math.max(prev.x, nodeLeft);
|
|
1611
|
+
const iy1 = Math.max(prev.y, nodeTop);
|
|
1612
|
+
const ix2 = Math.min(prev.x + prev.width, nodeRight);
|
|
1613
|
+
const iy2 = Math.min(prev.y + prev.height, nodeBottom);
|
|
1614
|
+
if (ix2 > ix1 && iy2 > iy1) return true;
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
779
1617
|
}
|
|
780
1618
|
if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) {
|
|
781
1619
|
if (_checkDescendantOverflow(child.children, nodeLeft, nodeTop, nodeRight, nodeBottom)) return true;
|
|
@@ -796,11 +1634,44 @@ function _checkDescendantOverflow(children, nodeLeft, nodeTop, nodeRight, nodeBo
|
|
|
796
1634
|
* offset changes even when scrollRect stays the same.
|
|
797
1635
|
*/
|
|
798
1636
|
function notifyLayoutSubscribers(node) {
|
|
799
|
-
rectEqual(node.prevLayout, node.boxRect);
|
|
800
|
-
|
|
801
|
-
rectEqual(node.
|
|
1637
|
+
const contentChanged = !rectEqual(node.prevLayout, node.boxRect);
|
|
1638
|
+
const sizeChanged = (node.prevLayout?.width ?? 0) !== (node.boxRect?.width ?? 0) || (node.prevLayout?.height ?? 0) !== (node.boxRect?.height ?? 0);
|
|
1639
|
+
const screenChanged = !rectEqual(node.prevScrollRect, node.scrollRect);
|
|
1640
|
+
const renderChanged = !rectEqual(node.prevScreenRect, node.screenRect);
|
|
1641
|
+
if (INSTRUMENT) {
|
|
1642
|
+
if (contentChanged || screenChanged || renderChanged) {
|
|
1643
|
+
if (hasLayoutSignals(node)) {
|
|
1644
|
+
const ident = nodeIdent(node);
|
|
1645
|
+
if (contentChanged && hasObservedLayoutSignal(node, "boxRect")) logPass({
|
|
1646
|
+
cause: "layout-invalidate",
|
|
1647
|
+
edge: "boxRect",
|
|
1648
|
+
nodeId: ident,
|
|
1649
|
+
producerPhase: "layout"
|
|
1650
|
+
});
|
|
1651
|
+
if (sizeChanged && hasObservedLayoutSignal(node, "boxSize")) logPass({
|
|
1652
|
+
cause: "layout-invalidate",
|
|
1653
|
+
edge: "boxSize",
|
|
1654
|
+
nodeId: ident,
|
|
1655
|
+
producerPhase: "layout"
|
|
1656
|
+
});
|
|
1657
|
+
if (screenChanged && hasObservedLayoutSignal(node, "scrollRect")) logPass({
|
|
1658
|
+
cause: "layout-invalidate",
|
|
1659
|
+
edge: "scrollRect",
|
|
1660
|
+
nodeId: ident,
|
|
1661
|
+
producerPhase: "layout"
|
|
1662
|
+
});
|
|
1663
|
+
if (renderChanged && hasObservedLayoutSignal(node, "screenRect")) logPass({
|
|
1664
|
+
cause: "layout-invalidate",
|
|
1665
|
+
edge: "screenRect",
|
|
1666
|
+
nodeId: ident,
|
|
1667
|
+
producerPhase: "layout"
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
802
1672
|
syncRectSignals(node);
|
|
803
1673
|
for (const child of node.children) notifyLayoutSubscribers(child);
|
|
1674
|
+
if (node.parent === null) syncDecorationRects(node);
|
|
804
1675
|
}
|
|
805
1676
|
/**
|
|
806
1677
|
* Verify that no child's boxRect.width exceeds its parent's inner content width.
|
|
@@ -843,9 +1714,24 @@ function strictLayoutOverflowCheck(root) {
|
|
|
843
1714
|
if (child.boxRect.width > parentInnerWidth) {
|
|
844
1715
|
const childId = childProps.id ?? child.type;
|
|
845
1716
|
const parentId = parentProps.id ?? node.type;
|
|
846
|
-
const
|
|
1717
|
+
const detail = {
|
|
1718
|
+
child: nodeIdent(child),
|
|
1719
|
+
parent: nodeIdent(node),
|
|
1720
|
+
childPath: nodePath(child),
|
|
1721
|
+
childBox: child.boxRect,
|
|
1722
|
+
parentBox: node.boxRect,
|
|
1723
|
+
parentInnerWidth,
|
|
1724
|
+
border,
|
|
1725
|
+
padding,
|
|
1726
|
+
childProps: layoutPropsForLog(child),
|
|
1727
|
+
parentProps: layoutPropsForLog(node)
|
|
1728
|
+
};
|
|
1729
|
+
const msg = `[SILVERY_STRICT] Layout overflow: child "${childId}" width ${child.boxRect.width} exceeds parent "${parentId}" inner width ${parentInnerWidth} (parent box: ${node.boxRect.width}, border: ${border.left}+${border.right}, padding: ${padding.left}+${padding.right}, path: ${detail.childPath})`;
|
|
847
1730
|
if (shouldThrow) throw new Error(msg);
|
|
848
|
-
else
|
|
1731
|
+
else {
|
|
1732
|
+
log$2.debug?.("layout overflow", detail);
|
|
1733
|
+
console.warn(msg);
|
|
1734
|
+
}
|
|
849
1735
|
}
|
|
850
1736
|
}
|
|
851
1737
|
walk(child);
|
|
@@ -941,18 +1827,38 @@ function calculateScrollState(node, props, skipStateUpdates) {
|
|
|
941
1827
|
const viewportHeight = rawViewportHeight;
|
|
942
1828
|
const indicatorReserve = props.overflowIndicator === true && !props.borderStyle && contentHeight > rawViewportHeight ? 1 : 0;
|
|
943
1829
|
const prevOffset = node.scrollState?.offset;
|
|
1830
|
+
const prevScrollTo = node.scrollState?.prevScrollTo;
|
|
944
1831
|
let scrollOffset = props.scrollOffset ?? prevOffset ?? 0;
|
|
945
1832
|
const scrollTo = props.scrollTo;
|
|
946
|
-
|
|
1833
|
+
const scrollToChanged = prevScrollTo !== scrollTo;
|
|
1834
|
+
let targetCompletelyOffscreen = false;
|
|
1835
|
+
if (!scrollToChanged && scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length) {
|
|
1836
|
+
const target = childPositions.find((c) => c.index === scrollTo);
|
|
1837
|
+
if (target && target.top !== target.bottom) {
|
|
1838
|
+
const visTop = scrollOffset;
|
|
1839
|
+
const visBottom = scrollOffset + viewportHeight;
|
|
1840
|
+
if (!(target.bottom > visTop && target.top < visBottom)) targetCompletelyOffscreen = true;
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
if (scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length && (scrollToChanged || targetCompletelyOffscreen)) {
|
|
947
1844
|
const target = childPositions.find((c) => c.index === scrollTo);
|
|
948
1845
|
if (target) {
|
|
1846
|
+
if (INSTRUMENT) logPass({
|
|
1847
|
+
cause: "scrollto-settle",
|
|
1848
|
+
edge: targetCompletelyOffscreen ? "scrollTo:recovery" : "scrollTo:newIntent",
|
|
1849
|
+
nodeId: nodeIdent(node),
|
|
1850
|
+
producerPhase: "scroll",
|
|
1851
|
+
detail: `target=${scrollTo}`
|
|
1852
|
+
});
|
|
949
1853
|
const effectiveHeight = viewportHeight - indicatorReserve;
|
|
950
1854
|
const visibleTop = scrollOffset;
|
|
951
1855
|
const visibleBottom = scrollOffset + effectiveHeight;
|
|
952
|
-
if (target.top
|
|
1856
|
+
if (target.bottom - target.top > effectiveHeight) scrollOffset = target.top > 0 ? target.top - 1 : 0;
|
|
1857
|
+
else if (target.top < visibleTop) scrollOffset = target.top > 0 ? target.top - 1 : 0;
|
|
953
1858
|
else if (target.bottom > visibleBottom) scrollOffset = snapOffsetToChildTop(target.bottom - effectiveHeight, childPositions, target);
|
|
954
1859
|
}
|
|
955
1860
|
}
|
|
1861
|
+
scrollOffset = normalizeScrollOffset(scrollOffset);
|
|
956
1862
|
scrollOffset = Math.max(0, scrollOffset);
|
|
957
1863
|
scrollOffset = Math.min(scrollOffset, Math.max(0, contentHeight - viewportHeight));
|
|
958
1864
|
const visibleTop = scrollOffset;
|
|
@@ -961,6 +1867,10 @@ function calculateScrollState(node, props, skipStateUpdates) {
|
|
|
961
1867
|
let lastVisible = -1;
|
|
962
1868
|
let hiddenAbove = 0;
|
|
963
1869
|
let hiddenBelow = 0;
|
|
1870
|
+
const logicalCount = (cp) => {
|
|
1871
|
+
const r = cp.child.props.representsItems;
|
|
1872
|
+
return r !== void 0 && r >= 0 ? r : 1;
|
|
1873
|
+
};
|
|
964
1874
|
for (const cp of childPositions) {
|
|
965
1875
|
if (cp.isSticky) {
|
|
966
1876
|
if (firstVisible === -1) firstVisible = cp.index;
|
|
@@ -968,15 +1878,20 @@ function calculateScrollState(node, props, skipStateUpdates) {
|
|
|
968
1878
|
continue;
|
|
969
1879
|
}
|
|
970
1880
|
if (cp.top === cp.bottom) continue;
|
|
971
|
-
if (cp.bottom <= visibleTop) hiddenAbove
|
|
972
|
-
else if (cp.top >= visibleBottom) hiddenBelow
|
|
1881
|
+
if (cp.bottom <= visibleTop) hiddenAbove += logicalCount(cp);
|
|
1882
|
+
else if (cp.top >= visibleBottom) hiddenBelow += logicalCount(cp);
|
|
973
1883
|
else if (cp.top < visibleTop) {
|
|
974
1884
|
if (firstVisible === -1) firstVisible = cp.index;
|
|
975
1885
|
lastVisible = Math.max(lastVisible, cp.index);
|
|
976
1886
|
} else if (cp.bottom > visibleBottom) {
|
|
977
1887
|
if (firstVisible === -1) firstVisible = cp.index;
|
|
978
1888
|
lastVisible = cp.index;
|
|
979
|
-
|
|
1889
|
+
const isLastChild = cp.index === childPositions[childPositions.length - 1]?.index;
|
|
1890
|
+
const rawViewportBottom = scrollOffset + viewportHeight;
|
|
1891
|
+
const childHeight = cp.bottom - cp.top;
|
|
1892
|
+
const isPhantomReserveCut = isLastChild && cp.bottom <= rawViewportBottom;
|
|
1893
|
+
const isOversizedLastAtEnd = isLastChild && childHeight > viewportHeight && scrollTo === cp.index;
|
|
1894
|
+
if (indicatorReserve > 0 && !isPhantomReserveCut && !isOversizedLastAtEnd) hiddenBelow += logicalCount(cp);
|
|
980
1895
|
} else {
|
|
981
1896
|
if (firstVisible === -1) firstVisible = cp.index;
|
|
982
1897
|
lastVisible = cp.index;
|
|
@@ -1006,6 +1921,7 @@ function calculateScrollState(node, props, skipStateUpdates) {
|
|
|
1006
1921
|
height: childHeight
|
|
1007
1922
|
});
|
|
1008
1923
|
}
|
|
1924
|
+
strictScrollInvariants(node, props, scrollOffset, contentHeight, viewportHeight, indicatorReserve, childPositions, firstVisible, lastVisible, stickyChildren);
|
|
1009
1925
|
if (skipStateUpdates) return;
|
|
1010
1926
|
const prevFirstVisible = node.scrollState?.firstVisibleChild ?? firstVisible;
|
|
1011
1927
|
const prevLastVisible = node.scrollState?.lastVisibleChild ?? lastVisible;
|
|
@@ -1019,6 +1935,7 @@ function calculateScrollState(node, props, skipStateUpdates) {
|
|
|
1019
1935
|
node.scrollState = {
|
|
1020
1936
|
offset: scrollOffset,
|
|
1021
1937
|
prevOffset: prevOffset ?? scrollOffset,
|
|
1938
|
+
prevScrollTo: scrollTo,
|
|
1022
1939
|
contentHeight,
|
|
1023
1940
|
viewportHeight,
|
|
1024
1941
|
firstVisibleChild: firstVisible,
|
|
@@ -1031,6 +1948,69 @@ function calculateScrollState(node, props, skipStateUpdates) {
|
|
|
1031
1948
|
};
|
|
1032
1949
|
}
|
|
1033
1950
|
/**
|
|
1951
|
+
* Runtime invariants for scroll state. Gated on SILVERY_STRICT.
|
|
1952
|
+
*
|
|
1953
|
+
* Invariant set (all violations use `[SILVERY_STRICT]` prefix, L1 warn / L2 throw):
|
|
1954
|
+
* 1. scrollOffset is clamped: 0 ≤ scrollOffset ≤ max(0, contentHeight - viewportHeight).
|
|
1955
|
+
* 2. If scrollTo is a valid index, the target child intersects the effective
|
|
1956
|
+
* viewport (viewport minus indicatorReserve) after the offset calc.
|
|
1957
|
+
* 3. firstVisibleChild / lastVisibleChild correspond to children that actually
|
|
1958
|
+
* intersect the effective viewport (not spacer indices outside).
|
|
1959
|
+
* 4. Sticky child renderOffset stays within legal viewport bounds — specifically
|
|
1960
|
+
* `renderOffset + height > 0 && renderOffset < viewportHeight` (sticky row
|
|
1961
|
+
* not clipped entirely out, which `calculateScrollState` already filters,
|
|
1962
|
+
* but we also verify the sticky row's bottom doesn't sit ABOVE 0 for
|
|
1963
|
+
* isSticking=true entries since clamping should have prevented that).
|
|
1964
|
+
*
|
|
1965
|
+
* These are GENERIC scroll invariants — no virtualizer knowledge. Any divergence
|
|
1966
|
+
* points to a bug in the scroll/sticky math itself.
|
|
1967
|
+
*/
|
|
1968
|
+
function strictScrollInvariants(node, props, scrollOffset, contentHeight, viewportHeight, indicatorReserve, childPositions, firstVisible, lastVisible, stickyChildren) {
|
|
1969
|
+
const strict = process?.env?.SILVERY_STRICT;
|
|
1970
|
+
if (!strict) return;
|
|
1971
|
+
const shouldThrow = strict === "2";
|
|
1972
|
+
const nodeId = props.id ?? node.type;
|
|
1973
|
+
const report = (msg) => {
|
|
1974
|
+
const full = `[SILVERY_STRICT] ${msg} (node: ${nodeId})`;
|
|
1975
|
+
if (shouldThrow) throw new Error(full);
|
|
1976
|
+
else console.warn(full);
|
|
1977
|
+
};
|
|
1978
|
+
const maxOffset = Math.max(0, contentHeight - viewportHeight);
|
|
1979
|
+
if (scrollOffset < 0) report(`scrollOffset ${scrollOffset} < 0`);
|
|
1980
|
+
else if (scrollOffset > maxOffset) report(`scrollOffset ${scrollOffset} exceeds max ${maxOffset} (contentHeight=${contentHeight}, viewportHeight=${viewportHeight})`);
|
|
1981
|
+
const scrollTo = props.scrollTo;
|
|
1982
|
+
if (scrollTo !== void 0 && scrollTo >= 0 && scrollTo < childPositions.length) {
|
|
1983
|
+
const target = childPositions.find((c) => c.index === scrollTo);
|
|
1984
|
+
if (target && target.top !== target.bottom) {
|
|
1985
|
+
const visibleTop = scrollOffset;
|
|
1986
|
+
const visibleBottom = scrollOffset + viewportHeight;
|
|
1987
|
+
if (!(target.bottom > visibleTop && target.top < visibleBottom)) report(`scrollTo target index=${scrollTo} does not intersect viewport (target [${target.top},${target.bottom}), visible [${visibleTop},${visibleBottom}), indicatorReserve=${indicatorReserve})`);
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
const visibleTop = scrollOffset;
|
|
1991
|
+
const visibleBottom = scrollOffset + viewportHeight - indicatorReserve;
|
|
1992
|
+
const checkVisible = (label, idx) => {
|
|
1993
|
+
if (idx < 0) return;
|
|
1994
|
+
const cp = childPositions.find((c) => c.index === idx);
|
|
1995
|
+
if (!cp) {
|
|
1996
|
+
report(`${label}=${idx} but no child at that index`);
|
|
1997
|
+
return;
|
|
1998
|
+
}
|
|
1999
|
+
if (cp.isSticky) return;
|
|
2000
|
+
if (cp.top === cp.bottom) {
|
|
2001
|
+
report(`${label}=${idx} references zero-height child`);
|
|
2002
|
+
return;
|
|
2003
|
+
}
|
|
2004
|
+
if (!(cp.bottom > visibleTop && cp.top < visibleBottom)) report(`${label}=${idx} does not intersect effective viewport (child [${cp.top},${cp.bottom}), visible [${visibleTop},${visibleBottom}))`);
|
|
2005
|
+
};
|
|
2006
|
+
checkVisible("firstVisibleChild", firstVisible);
|
|
2007
|
+
checkVisible("lastVisibleChild", lastVisible);
|
|
2008
|
+
for (const sc of stickyChildren) {
|
|
2009
|
+
const topRow = sc.renderOffset;
|
|
2010
|
+
if (sc.renderOffset + sc.height <= 0 || topRow >= viewportHeight) report(`sticky child index=${sc.index} renderOffset=${topRow} height=${sc.height} outside viewport [0,${viewportHeight})`);
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
1034
2014
|
* Compute sticky offsets for non-scroll containers that have sticky children.
|
|
1035
2015
|
*
|
|
1036
2016
|
* Scroll containers handle their own sticky logic in calculateScrollState().
|
|
@@ -1102,6 +2082,13 @@ function stickyPhase(root) {
|
|
|
1102
2082
|
node.dirtyBits = 16;
|
|
1103
2083
|
node.dirtyEpoch = epoch;
|
|
1104
2084
|
} else node.dirtyBits |= 16;
|
|
2085
|
+
if (INSTRUMENT) logPass({
|
|
2086
|
+
cause: "sticky-resettle",
|
|
2087
|
+
edge: "stickyChildren",
|
|
2088
|
+
nodeId: nodeIdent(node),
|
|
2089
|
+
producerPhase: "sticky",
|
|
2090
|
+
detail: `${prev?.length ?? 0}→${next?.length ?? 0}`
|
|
2091
|
+
});
|
|
1105
2092
|
}
|
|
1106
2093
|
});
|
|
1107
2094
|
}
|
|
@@ -1265,6 +2252,7 @@ function detectPipelineFeatures(root) {
|
|
|
1265
2252
|
}
|
|
1266
2253
|
//#endregion
|
|
1267
2254
|
//#region packages/ag-term/src/pipeline/state.ts
|
|
2255
|
+
init_src$1();
|
|
1268
2256
|
/**
|
|
1269
2257
|
* Safe fallback theme. Never mutated — the theme flows via the AgNode tree
|
|
1270
2258
|
* (Box theme= prop + pushContextTheme/popContextTheme in render-phase.ts).
|
|
@@ -1312,18 +2300,7 @@ function popContextTheme() {
|
|
|
1312
2300
|
}
|
|
1313
2301
|
//#endregion
|
|
1314
2302
|
//#region packages/ag-term/src/pipeline/render-helpers.ts
|
|
1315
|
-
|
|
1316
|
-
* Render Helpers - Pure utility functions for content rendering.
|
|
1317
|
-
*
|
|
1318
|
-
* Contains:
|
|
1319
|
-
* - Color parsing (parseColor)
|
|
1320
|
-
* - Border character definitions (getBorderChars)
|
|
1321
|
-
* - Style extraction (getTextStyle)
|
|
1322
|
-
* - Text width utilities (getTextWidth)
|
|
1323
|
-
*
|
|
1324
|
-
* Re-exports layout helpers from helpers.ts:
|
|
1325
|
-
* - getPadding, getBorderSize
|
|
1326
|
-
*/
|
|
2303
|
+
init_buffer();
|
|
1327
2304
|
init_src();
|
|
1328
2305
|
const namedColors = {
|
|
1329
2306
|
black: 0,
|
|
@@ -1388,7 +2365,7 @@ function parseColor(color) {
|
|
|
1388
2365
|
}
|
|
1389
2366
|
}
|
|
1390
2367
|
if (color.startsWith("$")) {
|
|
1391
|
-
if (getActiveColorLevel() === "
|
|
2368
|
+
if (getActiveColorLevel() === "mono") return null;
|
|
1392
2369
|
const resolved = resolveThemeColor(color, getActiveTheme());
|
|
1393
2370
|
if (resolved && resolved !== color) return parseColor(resolved);
|
|
1394
2371
|
return null;
|
|
@@ -1523,14 +2500,16 @@ function collectMonoAttrs(color, into) {
|
|
|
1523
2500
|
function getTextStyle(props) {
|
|
1524
2501
|
let underlineStyle;
|
|
1525
2502
|
if (props.underlineStyle !== void 0) underlineStyle = props.underlineStyle;
|
|
1526
|
-
else if (props.underline) underlineStyle =
|
|
2503
|
+
else if (typeof props.underline === "string") underlineStyle = props.underline;
|
|
2504
|
+
else if (props.underline === true) underlineStyle = "single";
|
|
1527
2505
|
let bold = props.bold;
|
|
1528
|
-
let dim
|
|
2506
|
+
let dim;
|
|
1529
2507
|
let italic = props.italic;
|
|
1530
|
-
let underline = props.underline || !!underlineStyle;
|
|
2508
|
+
let underline = !!props.underline || !!underlineStyle;
|
|
1531
2509
|
let strikethrough = props.strikethrough;
|
|
1532
2510
|
let inverse = props.inverse;
|
|
1533
|
-
|
|
2511
|
+
const overline = !!props.overline;
|
|
2512
|
+
if (getActiveColorLevel() === "mono") {
|
|
1534
2513
|
const monoAttrs = /* @__PURE__ */ new Set();
|
|
1535
2514
|
collectMonoAttrs(props.color, monoAttrs);
|
|
1536
2515
|
collectMonoAttrs(props.backgroundColor, monoAttrs);
|
|
@@ -1554,6 +2533,7 @@ function getTextStyle(props) {
|
|
|
1554
2533
|
italic,
|
|
1555
2534
|
underline,
|
|
1556
2535
|
underlineStyle,
|
|
2536
|
+
overline,
|
|
1557
2537
|
strikethrough,
|
|
1558
2538
|
inverse
|
|
1559
2539
|
}
|
|
@@ -1572,6 +2552,580 @@ function getTextWidth(text, ctx) {
|
|
|
1572
2552
|
return displayWidthAnsi(text);
|
|
1573
2553
|
}
|
|
1574
2554
|
//#endregion
|
|
2555
|
+
//#region packages/ag-term/src/strict-mode.ts
|
|
2556
|
+
let cachedRaw = void 0;
|
|
2557
|
+
let cachedParsed = null;
|
|
2558
|
+
function parseStrict(raw) {
|
|
2559
|
+
if (!raw || raw === "0") return {
|
|
2560
|
+
enabledTiers: /* @__PURE__ */ new Set(),
|
|
2561
|
+
enabledSlugs: /* @__PURE__ */ new Set(),
|
|
2562
|
+
disabledSlugs: /* @__PURE__ */ new Set(),
|
|
2563
|
+
raw
|
|
2564
|
+
};
|
|
2565
|
+
const tiers = /* @__PURE__ */ new Set();
|
|
2566
|
+
const slugs = /* @__PURE__ */ new Set();
|
|
2567
|
+
const disabled = /* @__PURE__ */ new Set();
|
|
2568
|
+
for (const part of raw.split(",")) {
|
|
2569
|
+
const trimmed = part.trim();
|
|
2570
|
+
if (!trimmed) continue;
|
|
2571
|
+
if (trimmed.startsWith("!")) {
|
|
2572
|
+
disabled.add(trimmed.slice(1));
|
|
2573
|
+
continue;
|
|
2574
|
+
}
|
|
2575
|
+
const n = Number(trimmed);
|
|
2576
|
+
if (Number.isFinite(n) && n >= 1) {
|
|
2577
|
+
tiers.add(n);
|
|
2578
|
+
continue;
|
|
2579
|
+
}
|
|
2580
|
+
slugs.add(trimmed);
|
|
2581
|
+
}
|
|
2582
|
+
return {
|
|
2583
|
+
enabledTiers: tiers,
|
|
2584
|
+
enabledSlugs: slugs,
|
|
2585
|
+
disabledSlugs: disabled,
|
|
2586
|
+
raw
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
function getParsed() {
|
|
2590
|
+
const raw = process.env.SILVERY_STRICT;
|
|
2591
|
+
if (raw !== cachedRaw || cachedParsed === null) {
|
|
2592
|
+
cachedRaw = raw;
|
|
2593
|
+
cachedParsed = parseStrict(raw);
|
|
2594
|
+
}
|
|
2595
|
+
return cachedParsed;
|
|
2596
|
+
}
|
|
2597
|
+
/**
|
|
2598
|
+
* Returns true if `slug` should fire under the current `SILVERY_STRICT`.
|
|
2599
|
+
*
|
|
2600
|
+
* - `!slug` always wins (per-check skip)
|
|
2601
|
+
* - explicit slug match wins
|
|
2602
|
+
* - any tier >= minTier (default 1) implies the check
|
|
2603
|
+
*/
|
|
2604
|
+
function isStrictEnabled(slug, minTier = 1) {
|
|
2605
|
+
const p = getParsed();
|
|
2606
|
+
if (p.disabledSlugs.has(slug)) return false;
|
|
2607
|
+
if (p.enabledSlugs.has(slug)) return true;
|
|
2608
|
+
for (const tier of p.enabledTiers) if (tier >= minTier) return true;
|
|
2609
|
+
return false;
|
|
2610
|
+
}
|
|
2611
|
+
//#endregion
|
|
2612
|
+
//#region packages/ag-term/src/strict-bordered-rect.ts
|
|
2613
|
+
/** SILVERY_STRICT slug for the bordered-rect clip check. Tier 2 by design. */
|
|
2614
|
+
const BORDERED_RECT_CLIP_SLUG = "bordered-rect-clip";
|
|
2615
|
+
/** Returns true when the bordered-rect clip check should fire. */
|
|
2616
|
+
function isBorderedRectClipEnabled() {
|
|
2617
|
+
return isStrictEnabled(BORDERED_RECT_CLIP_SLUG, 2);
|
|
2618
|
+
}
|
|
2619
|
+
/**
|
|
2620
|
+
* Error thrown when a cell is painted outside the nearest bordered
|
|
2621
|
+
* ancestor's inner content rect under SILVERY_STRICT=bordered-rect-clip.
|
|
2622
|
+
*
|
|
2623
|
+
* Like `IncrementalRenderMismatchError`, this should NOT be caught by
|
|
2624
|
+
* general error handlers — it indicates a real layout/clip bug that
|
|
2625
|
+
* needs a structural fix (`overflow="hidden"` on the bordered Box, or
|
|
2626
|
+
* `wrap="truncate"` on the inner Text).
|
|
2627
|
+
*/
|
|
2628
|
+
var BorderedRectClipError = class extends Error {
|
|
2629
|
+
constructor(message) {
|
|
2630
|
+
super(message);
|
|
2631
|
+
this.name = "BorderedRectClipError";
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2634
|
+
const renderingStack = [];
|
|
2635
|
+
/** Push the node currently being painted onto the rendering stack. */
|
|
2636
|
+
function pushRenderingNode(node) {
|
|
2637
|
+
renderingStack.push(node);
|
|
2638
|
+
}
|
|
2639
|
+
/** Pop the most recently pushed rendering node. */
|
|
2640
|
+
function popRenderingNode() {
|
|
2641
|
+
renderingStack.pop();
|
|
2642
|
+
}
|
|
2643
|
+
/** Peek the topmost rendering node, or null if the stack is empty. */
|
|
2644
|
+
function peekRenderingNode() {
|
|
2645
|
+
return renderingStack.length === 0 ? null : renderingStack[renderingStack.length - 1] ?? null;
|
|
2646
|
+
}
|
|
2647
|
+
/**
|
|
2648
|
+
* Walk `node.parent` until we find a node with `borderStyle != null`.
|
|
2649
|
+
* Returns null when no ancestor is bordered (the outer canvas itself
|
|
2650
|
+
* has no border to honor).
|
|
2651
|
+
*
|
|
2652
|
+
* Excludes the starting node — a bordered Box's own border cells sit on
|
|
2653
|
+
* its perimeter (outside its OWN inner rect). The relevant constraint is
|
|
2654
|
+
* "stay inside the parent that borders me", not "stay inside myself".
|
|
2655
|
+
*/
|
|
2656
|
+
function findBorderedAncestor(node) {
|
|
2657
|
+
let current = node.parent;
|
|
2658
|
+
while (current) {
|
|
2659
|
+
const props = current.props;
|
|
2660
|
+
if (props && props.borderStyle != null) return current;
|
|
2661
|
+
current = current.parent;
|
|
2662
|
+
}
|
|
2663
|
+
return null;
|
|
2664
|
+
}
|
|
2665
|
+
/**
|
|
2666
|
+
* Bordered-ancestor lookup result, cached against the topmost rendering
|
|
2667
|
+
* node identity. The renderer dispatches one `renderText` / `renderBox`
|
|
2668
|
+
* call per AgNode, and inside that call every emitSetCell shares the
|
|
2669
|
+
* same currently-rendering node. So a single-slot cache amortizes the
|
|
2670
|
+
* O(depth) walk to once per dispatch, not per cell.
|
|
2671
|
+
*
|
|
2672
|
+
* `cachedFor === null` means "no entry"; `cachedAncestor === null`
|
|
2673
|
+
* means "no bordered ancestor exists" (legitimate — outer-canvas paint).
|
|
2674
|
+
*/
|
|
2675
|
+
let cachedFor = null;
|
|
2676
|
+
let cachedAncestor = null;
|
|
2677
|
+
let cachedSkip = false;
|
|
2678
|
+
function getBorderedAncestorCached(node) {
|
|
2679
|
+
if (cachedFor === node) return {
|
|
2680
|
+
ancestor: cachedAncestor,
|
|
2681
|
+
skip: cachedSkip
|
|
2682
|
+
};
|
|
2683
|
+
cachedFor = node;
|
|
2684
|
+
cachedAncestor = findBorderedAncestor(node);
|
|
2685
|
+
cachedSkip = cachedAncestor != null && hasOverflowHidden(cachedAncestor);
|
|
2686
|
+
return {
|
|
2687
|
+
ancestor: cachedAncestor,
|
|
2688
|
+
skip: cachedSkip
|
|
2689
|
+
};
|
|
2690
|
+
}
|
|
2691
|
+
function hasOverflowHidden(node) {
|
|
2692
|
+
const p = node.props;
|
|
2693
|
+
if (!p) return false;
|
|
2694
|
+
if (p.overflow === "hidden") return true;
|
|
2695
|
+
if (p.overflowX === "hidden" && p.overflowY === "hidden") return true;
|
|
2696
|
+
return false;
|
|
2697
|
+
}
|
|
2698
|
+
/** Reset the per-render cache. Called at the boundary of each rendering
|
|
2699
|
+
* push/pop so a stale identity from a previous frame never leaks. */
|
|
2700
|
+
function resetBorderedRectClipCache() {
|
|
2701
|
+
cachedFor = null;
|
|
2702
|
+
cachedAncestor = null;
|
|
2703
|
+
cachedSkip = false;
|
|
2704
|
+
}
|
|
2705
|
+
function computeInnerRect(box, props) {
|
|
2706
|
+
const top = props.borderTop !== false ? 1 : 0;
|
|
2707
|
+
const bottom = props.borderBottom !== false ? 1 : 0;
|
|
2708
|
+
const left = props.borderLeft !== false ? 1 : 0;
|
|
2709
|
+
const right = props.borderRight !== false ? 1 : 0;
|
|
2710
|
+
return {
|
|
2711
|
+
left: box.x + left,
|
|
2712
|
+
top: box.y + top,
|
|
2713
|
+
right: box.x + box.width - right,
|
|
2714
|
+
bottom: box.y + box.height - bottom
|
|
2715
|
+
};
|
|
2716
|
+
}
|
|
2717
|
+
/**
|
|
2718
|
+
* Assert that cell (x, y) lies inside the nearest bordered ancestor's
|
|
2719
|
+
* inner content rect. No-op when:
|
|
2720
|
+
* - the strict slug is not enabled
|
|
2721
|
+
* - no rendering node is on the stack (paint outside a renderer
|
|
2722
|
+
* dispatch — e.g. early init clear)
|
|
2723
|
+
* - no bordered ancestor exists
|
|
2724
|
+
* - the bordered ancestor has overflow="hidden" (clip enforced
|
|
2725
|
+
* elsewhere by computeChildClipBounds)
|
|
2726
|
+
*
|
|
2727
|
+
* Throws `BorderedRectClipError` on violation.
|
|
2728
|
+
*/
|
|
2729
|
+
function assertCellInsideBorderedRect(x, y, opts = {}) {
|
|
2730
|
+
if (!isBorderedRectClipEnabled()) return;
|
|
2731
|
+
const node = peekRenderingNode();
|
|
2732
|
+
if (!node) return;
|
|
2733
|
+
const { ancestor, skip } = getBorderedAncestorCached(node);
|
|
2734
|
+
if (!ancestor || skip) return;
|
|
2735
|
+
const box = ancestor.screenRect ?? ancestor.scrollRect ?? ancestor.boxRect;
|
|
2736
|
+
if (!box) return;
|
|
2737
|
+
const props = ancestor.props;
|
|
2738
|
+
const inner = computeInnerRect(box, props);
|
|
2739
|
+
if (x >= inner.left && x < inner.right && y >= inner.top && y < inner.bottom) return;
|
|
2740
|
+
const ancestorId = describeNode(ancestor);
|
|
2741
|
+
const nodeId = describeNode(node);
|
|
2742
|
+
const path = describeDepthPath(node);
|
|
2743
|
+
throw new BorderedRectClipError(`STRICT bordered-rect-clip: cell painted outside ancestor's bordered rect\n cell: (x=${x}, y=${y}) char=${opts.char ? `'${opts.char}'` : "(unknown)"} bg=${formatColor(opts.bg)} fg=${formatColor(opts.fg)}\n parent box: id='${ancestorId}' rect=(${box.x},${box.y},${box.width},${box.height}) borderStyle='${props.borderStyle}'\n inner rect: (left=${inner.left}, top=${inner.top}, right=${inner.right}, bottom=${inner.bottom}) [exclusive]\n painter: '${nodeId}'\n depth path: ${path}\n\n Suggested fix: set \`overflow="hidden"\` on the bordered Box,\n or use \`wrap="wrap"\` / \`wrap="truncate"\` on the inner Text.\n\n Slug: SILVERY_STRICT=${BORDERED_RECT_CLIP_SLUG} (tier 2+).\n Per-test opt-out: SILVERY_STRICT=2,!${BORDERED_RECT_CLIP_SLUG}.`);
|
|
2744
|
+
}
|
|
2745
|
+
function describeNode(node) {
|
|
2746
|
+
const key = node.props?.key;
|
|
2747
|
+
if (key !== void 0) return `${node.type}[key=${String(key)}]`;
|
|
2748
|
+
if (node.type === "silvery-text" && typeof node.textContent === "string") {
|
|
2749
|
+
const t = node.textContent.length > 20 ? `${node.textContent.slice(0, 20)}…` : node.textContent;
|
|
2750
|
+
return `${node.type}[${JSON.stringify(t)}]`;
|
|
2751
|
+
}
|
|
2752
|
+
return `${node.type}`;
|
|
2753
|
+
}
|
|
2754
|
+
function describeDepthPath(node) {
|
|
2755
|
+
const parts = [];
|
|
2756
|
+
let cur = node;
|
|
2757
|
+
let depth = 0;
|
|
2758
|
+
while (cur && depth < 8) {
|
|
2759
|
+
const props = cur.props;
|
|
2760
|
+
let label = cur.type;
|
|
2761
|
+
if (props?.borderStyle) label += `[border=${props.borderStyle}]`;
|
|
2762
|
+
parts.unshift(label);
|
|
2763
|
+
cur = cur.parent;
|
|
2764
|
+
depth++;
|
|
2765
|
+
}
|
|
2766
|
+
if (cur) parts.unshift("…");
|
|
2767
|
+
return parts.join(" > ");
|
|
2768
|
+
}
|
|
2769
|
+
function formatColor(c) {
|
|
2770
|
+
if (c === null || c === void 0) return "default";
|
|
2771
|
+
if (typeof c === "number") return `${c}`;
|
|
2772
|
+
if (typeof c === "object") {
|
|
2773
|
+
const o = c;
|
|
2774
|
+
if (typeof o.r === "number") return `#${(o.r << 16 | o.g << 8 | o.b).toString(16).padStart(6, "0")}`;
|
|
2775
|
+
}
|
|
2776
|
+
return String(c);
|
|
2777
|
+
}
|
|
2778
|
+
//#endregion
|
|
2779
|
+
//#region packages/ag-term/src/pipeline/render-sink.ts
|
|
2780
|
+
function cloneCellPatch(cell) {
|
|
2781
|
+
const out = { ...cell };
|
|
2782
|
+
if (cell.attrs) out.attrs = { ...cell.attrs };
|
|
2783
|
+
return out;
|
|
2784
|
+
}
|
|
2785
|
+
function cloneCell(cell) {
|
|
2786
|
+
return {
|
|
2787
|
+
...cell,
|
|
2788
|
+
attrs: { ...cell.attrs }
|
|
2789
|
+
};
|
|
2790
|
+
}
|
|
2791
|
+
function cloneStyle(style) {
|
|
2792
|
+
return {
|
|
2793
|
+
...style,
|
|
2794
|
+
attrs: { ...style.attrs }
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
/**
|
|
2798
|
+
* `RenderSink` implementation that mutates a `TerminalBuffer` directly.
|
|
2799
|
+
* Behavior-equivalent to the legacy direct-mutation path. Used during
|
|
2800
|
+
* the Phase 2 transition: renderers route through the sink, but with
|
|
2801
|
+
* `BufferSink` the actual buffer mutation is unchanged.
|
|
2802
|
+
*
|
|
2803
|
+
* Once every renderer call site routes through a sink, swapping
|
|
2804
|
+
* `BufferSink` for `PlanSink` at one entry point flips the renderer
|
|
2805
|
+
* onto the plan/commit substrate.
|
|
2806
|
+
*/
|
|
2807
|
+
var BufferSink = class {
|
|
2808
|
+
constructor(buffer) {
|
|
2809
|
+
this.buffer = buffer;
|
|
2810
|
+
}
|
|
2811
|
+
selectableCell(cell, selectable) {
|
|
2812
|
+
return selectable === void 0 ? cell : {
|
|
2813
|
+
...cell,
|
|
2814
|
+
selectable
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
get width() {
|
|
2818
|
+
return this.buffer.width;
|
|
2819
|
+
}
|
|
2820
|
+
get height() {
|
|
2821
|
+
return this.buffer.height;
|
|
2822
|
+
}
|
|
2823
|
+
emitScrollRegion(x, y, width, height, delta, clearCell = {}, selectable) {
|
|
2824
|
+
this.buffer.scrollRegion(x, y, width, height, delta, this.selectableCell(clearCell, selectable));
|
|
2825
|
+
}
|
|
2826
|
+
emitClearRect(x, y, width, height, bg, selectable) {
|
|
2827
|
+
this.buffer.fill(x, y, width, height, {
|
|
2828
|
+
char: " ",
|
|
2829
|
+
bg,
|
|
2830
|
+
selectable
|
|
2831
|
+
});
|
|
2832
|
+
}
|
|
2833
|
+
emitClearCells(x, y, width, height, cell, selectable) {
|
|
2834
|
+
this.buffer.fill(x, y, width, height, this.selectableCell(cell, selectable));
|
|
2835
|
+
}
|
|
2836
|
+
emitSetCell(x, y, cell, selectable) {
|
|
2837
|
+
assertCellInsideBorderedRect(x, y, {
|
|
2838
|
+
char: cell.char,
|
|
2839
|
+
fg: cell.fg,
|
|
2840
|
+
bg: cell.bg
|
|
2841
|
+
});
|
|
2842
|
+
this.buffer.setCell(x, y, this.selectableCell(cell, selectable));
|
|
2843
|
+
}
|
|
2844
|
+
emitPaintFill(x, y, width, height, cell, selectable) {
|
|
2845
|
+
this.buffer.fill(x, y, width, height, this.selectableCell(cell, selectable));
|
|
2846
|
+
}
|
|
2847
|
+
emitFillBg(x, y, width, height, bg) {
|
|
2848
|
+
this.buffer.fillBg(x, y, width, height, bg);
|
|
2849
|
+
}
|
|
2850
|
+
emitRestyleRegion(x, y, width, height, style) {
|
|
2851
|
+
this.buffer.restyleRegion(x, y, width, height, style);
|
|
2852
|
+
}
|
|
2853
|
+
emitMergeAttrs(x, y, width, height, attrs, underlineColor) {
|
|
2854
|
+
this.buffer.mergeAttrsInRect(x, y, width, height, attrs, underlineColor);
|
|
2855
|
+
}
|
|
2856
|
+
setRowMeta(row, meta) {
|
|
2857
|
+
this.buffer.setRowMeta(row, meta);
|
|
2858
|
+
}
|
|
2859
|
+
setOutlineSnapshots(_snapshots) {}
|
|
2860
|
+
};
|
|
2861
|
+
/**
|
|
2862
|
+
* `RenderSink` that fans every emission out to a PRIMARY sink and a
|
|
2863
|
+
* SECONDARY sink. Used by the SILVERY_RENDER_PLAN production wiring:
|
|
2864
|
+
*
|
|
2865
|
+
* - primary = `BufferSink(buffer)`: keeps direct buffer mutation so
|
|
2866
|
+
* intra-frame buffer reads (`getCellBg`, dirty rows, snapshot reads)
|
|
2867
|
+
* return correct values during the render walk. Read-elimination is
|
|
2868
|
+
* the Phase 2 Step 6 follow-up.
|
|
2869
|
+
* - secondary = `PlanSink`: captures the same emissions into a
|
|
2870
|
+
* `SectionedRenderPlan` for parity verification or for committing
|
|
2871
|
+
* onto a fresh clone in plan-mode.
|
|
2872
|
+
*
|
|
2873
|
+
* The Tee fans every method to both sinks unconditionally. Since both
|
|
2874
|
+
* are in lock-step, the plan captures exactly the ops that were applied
|
|
2875
|
+
* to the buffer.
|
|
2876
|
+
*/
|
|
2877
|
+
var TeeSink = class {
|
|
2878
|
+
constructor(primary, secondary) {
|
|
2879
|
+
this.primary = primary;
|
|
2880
|
+
this.secondary = secondary;
|
|
2881
|
+
}
|
|
2882
|
+
get width() {
|
|
2883
|
+
return this.primary.width;
|
|
2884
|
+
}
|
|
2885
|
+
get height() {
|
|
2886
|
+
return this.primary.height;
|
|
2887
|
+
}
|
|
2888
|
+
emitScrollRegion(x, y, width, height, delta, clearCell, selectable) {
|
|
2889
|
+
this.primary.emitScrollRegion(x, y, width, height, delta, clearCell, selectable);
|
|
2890
|
+
this.secondary.emitScrollRegion(x, y, width, height, delta, clearCell, selectable);
|
|
2891
|
+
}
|
|
2892
|
+
emitClearRect(x, y, width, height, bg, selectable) {
|
|
2893
|
+
this.primary.emitClearRect(x, y, width, height, bg, selectable);
|
|
2894
|
+
this.secondary.emitClearRect(x, y, width, height, bg, selectable);
|
|
2895
|
+
}
|
|
2896
|
+
emitClearCells(x, y, width, height, cell, selectable) {
|
|
2897
|
+
this.primary.emitClearCells(x, y, width, height, cell, selectable);
|
|
2898
|
+
this.secondary.emitClearCells(x, y, width, height, cell, selectable);
|
|
2899
|
+
}
|
|
2900
|
+
emitSetCell(x, y, cell, selectable) {
|
|
2901
|
+
this.primary.emitSetCell(x, y, cell, selectable);
|
|
2902
|
+
this.secondary.emitSetCell(x, y, cell, selectable);
|
|
2903
|
+
}
|
|
2904
|
+
emitPaintFill(x, y, width, height, cell, selectable) {
|
|
2905
|
+
this.primary.emitPaintFill(x, y, width, height, cell, selectable);
|
|
2906
|
+
this.secondary.emitPaintFill(x, y, width, height, cell, selectable);
|
|
2907
|
+
}
|
|
2908
|
+
emitFillBg(x, y, width, height, bg) {
|
|
2909
|
+
this.primary.emitFillBg(x, y, width, height, bg);
|
|
2910
|
+
this.secondary.emitFillBg(x, y, width, height, bg);
|
|
2911
|
+
}
|
|
2912
|
+
emitRestyleRegion(x, y, width, height, style) {
|
|
2913
|
+
this.primary.emitRestyleRegion(x, y, width, height, style);
|
|
2914
|
+
this.secondary.emitRestyleRegion(x, y, width, height, style);
|
|
2915
|
+
}
|
|
2916
|
+
emitMergeAttrs(x, y, width, height, attrs, underlineColor) {
|
|
2917
|
+
this.primary.emitMergeAttrs(x, y, width, height, attrs, underlineColor);
|
|
2918
|
+
this.secondary.emitMergeAttrs(x, y, width, height, attrs, underlineColor);
|
|
2919
|
+
}
|
|
2920
|
+
setRowMeta(row, meta) {
|
|
2921
|
+
this.primary.setRowMeta(row, meta);
|
|
2922
|
+
this.secondary.setRowMeta(row, meta);
|
|
2923
|
+
}
|
|
2924
|
+
setOutlineSnapshots(snapshots) {
|
|
2925
|
+
this.primary.setOutlineSnapshots(snapshots);
|
|
2926
|
+
this.secondary.setOutlineSnapshots(snapshots);
|
|
2927
|
+
}
|
|
2928
|
+
};
|
|
2929
|
+
/**
|
|
2930
|
+
* The PlanSink that the current frame's BufferSink instances should also
|
|
2931
|
+
* record to (when SILVERY_RENDER_PLAN is enabled). Set by `withPlanCapture`
|
|
2932
|
+
* around a render-phase call; null at all other times.
|
|
2933
|
+
*
|
|
2934
|
+
* Module-level state because every renderer in render-phase / render-box /
|
|
2935
|
+
* render-text / decoration-phase constructs its own local sink. The
|
|
2936
|
+
* factory below reads this so all of them emit to the same plan.
|
|
2937
|
+
*/
|
|
2938
|
+
let _frameCapturePlanSink = null;
|
|
2939
|
+
/**
|
|
2940
|
+
* Run `fn` with frame-level plan capture enabled. Returns the plan
|
|
2941
|
+
* captured during `fn` along with whatever `fn` returned.
|
|
2942
|
+
*
|
|
2943
|
+
* Production wire path for SILVERY_RENDER_PLAN: ag.ts wraps the
|
|
2944
|
+
* `renderPhase()` call in `withPlanCapture(...)` and gets back both the
|
|
2945
|
+
* rendered buffer (already mutated by BufferSink-on-real-buffer) and the
|
|
2946
|
+
* matching `SectionedRenderPlan`. The plan can then be replayed onto a
|
|
2947
|
+
* fresh clone for parity verification, or used directly when buffer-read
|
|
2948
|
+
* elimination (Step 6) lets PlanSink stand alone.
|
|
2949
|
+
*/
|
|
2950
|
+
function withPlanCapture(width, height, fn) {
|
|
2951
|
+
const captured = new PlanSink(width, height);
|
|
2952
|
+
const prev = _frameCapturePlanSink;
|
|
2953
|
+
_frameCapturePlanSink = captured;
|
|
2954
|
+
try {
|
|
2955
|
+
return {
|
|
2956
|
+
result: fn(),
|
|
2957
|
+
plan: captured.toPlan()
|
|
2958
|
+
};
|
|
2959
|
+
} finally {
|
|
2960
|
+
_frameCapturePlanSink = prev;
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
/**
|
|
2964
|
+
* Construct the right sink for a renderer call site. By default
|
|
2965
|
+
* (`SILVERY_RENDER_PLAN` not set), returns a plain `BufferSink(buffer)`.
|
|
2966
|
+
*
|
|
2967
|
+
* When inside a `withPlanCapture` scope, returns a `TeeSink` that fans
|
|
2968
|
+
* every emission to BOTH the BufferSink AND the frame-shared PlanSink.
|
|
2969
|
+
* This is how every local sink construction in render-phase /
|
|
2970
|
+
* render-box / render-text / decoration-phase contributes to a single
|
|
2971
|
+
* SectionedRenderPlan for the frame.
|
|
2972
|
+
*/
|
|
2973
|
+
function createFrameSink(buffer) {
|
|
2974
|
+
const direct = new BufferSink(buffer);
|
|
2975
|
+
if (_frameCapturePlanSink === null) return direct;
|
|
2976
|
+
return new TeeSink(direct, _frameCapturePlanSink);
|
|
2977
|
+
}
|
|
2978
|
+
/**
|
|
2979
|
+
* `RenderSink` implementation that builds a `SectionedRenderPlan` directly
|
|
2980
|
+
* — no heuristics, no backing buffer for reads (yet — see file header
|
|
2981
|
+
* REMAINING). Each emit method routes the op into the corresponding
|
|
2982
|
+
* section.
|
|
2983
|
+
*
|
|
2984
|
+
* This is the L4 substrate: ops land in their section by API call, not
|
|
2985
|
+
* by classification of post-hoc mutations. Wrong-order paint becomes
|
|
2986
|
+
* unrepresentable because a clear API call cannot land in `paintOps` —
|
|
2987
|
+
* it goes through `emitClearRect` which writes to `cleanupOps`.
|
|
2988
|
+
*
|
|
2989
|
+
* Limitation: the sink has no buffer to back reads. Renderers that
|
|
2990
|
+
* currently read from the buffer mid-frame (`getCellBg`, dirty row
|
|
2991
|
+
* checks, outline snapshot reads) will not work with `PlanSink`
|
|
2992
|
+
* directly. Phase 2 Step 4 audits and eliminates those reads. Until
|
|
2993
|
+
* then, `PlanSink` is for synthetic plans + tests; production routing
|
|
2994
|
+
* waits on Step 4.
|
|
2995
|
+
*/
|
|
2996
|
+
var PlanSink = class {
|
|
2997
|
+
transferOps = [];
|
|
2998
|
+
cleanupOps = [];
|
|
2999
|
+
paintOps = [];
|
|
3000
|
+
overlayOps = [];
|
|
3001
|
+
postStateOps = [];
|
|
3002
|
+
constructor(width, height) {
|
|
3003
|
+
this.width = width;
|
|
3004
|
+
this.height = height;
|
|
3005
|
+
}
|
|
3006
|
+
emitScrollRegion(x, y, width, height, delta, clearCell, selectable) {
|
|
3007
|
+
this.transferOps.push({
|
|
3008
|
+
kind: "scrollRegion",
|
|
3009
|
+
x,
|
|
3010
|
+
y,
|
|
3011
|
+
width,
|
|
3012
|
+
height,
|
|
3013
|
+
delta,
|
|
3014
|
+
clearCell: clearCell ? this.selectableCell(clearCell, selectable) : void 0,
|
|
3015
|
+
selectable
|
|
3016
|
+
});
|
|
3017
|
+
}
|
|
3018
|
+
emitClearRect(x, y, width, height, bg, selectable) {
|
|
3019
|
+
this.cleanupOps.push({
|
|
3020
|
+
kind: "clearRect",
|
|
3021
|
+
x,
|
|
3022
|
+
y,
|
|
3023
|
+
width,
|
|
3024
|
+
height,
|
|
3025
|
+
bg,
|
|
3026
|
+
selectable
|
|
3027
|
+
});
|
|
3028
|
+
}
|
|
3029
|
+
emitClearCells(x, y, width, height, cell, selectable) {
|
|
3030
|
+
this.cleanupOps.push({
|
|
3031
|
+
kind: "clearCells",
|
|
3032
|
+
x,
|
|
3033
|
+
y,
|
|
3034
|
+
width,
|
|
3035
|
+
height,
|
|
3036
|
+
cell: this.selectableCell(cell, selectable)
|
|
3037
|
+
});
|
|
3038
|
+
}
|
|
3039
|
+
emitSetCell(x, y, cell, selectable) {
|
|
3040
|
+
this.paintOps.push({
|
|
3041
|
+
kind: "setCell",
|
|
3042
|
+
x,
|
|
3043
|
+
y,
|
|
3044
|
+
cell: this.selectableCell(cell, selectable)
|
|
3045
|
+
});
|
|
3046
|
+
}
|
|
3047
|
+
emitPaintFill(x, y, width, height, cell, selectable) {
|
|
3048
|
+
this.paintOps.push({
|
|
3049
|
+
kind: "paintFill",
|
|
3050
|
+
x,
|
|
3051
|
+
y,
|
|
3052
|
+
width,
|
|
3053
|
+
height,
|
|
3054
|
+
cell: this.selectableCell(cell, selectable)
|
|
3055
|
+
});
|
|
3056
|
+
}
|
|
3057
|
+
emitFillBg(x, y, width, height, bg) {
|
|
3058
|
+
this.paintOps.push({
|
|
3059
|
+
kind: "fillBg",
|
|
3060
|
+
x,
|
|
3061
|
+
y,
|
|
3062
|
+
width,
|
|
3063
|
+
height,
|
|
3064
|
+
bg
|
|
3065
|
+
});
|
|
3066
|
+
}
|
|
3067
|
+
emitRestyleRegion(x, y, width, height, style) {
|
|
3068
|
+
this.paintOps.push({
|
|
3069
|
+
kind: "restyleRegion",
|
|
3070
|
+
x,
|
|
3071
|
+
y,
|
|
3072
|
+
width,
|
|
3073
|
+
height,
|
|
3074
|
+
style: cloneStyle(style)
|
|
3075
|
+
});
|
|
3076
|
+
}
|
|
3077
|
+
emitMergeAttrs(x, y, width, height, attrs, underlineColor) {
|
|
3078
|
+
this.overlayOps.push({
|
|
3079
|
+
kind: "mergeAttrsInRect",
|
|
3080
|
+
x,
|
|
3081
|
+
y,
|
|
3082
|
+
width,
|
|
3083
|
+
height,
|
|
3084
|
+
attrs: { ...attrs },
|
|
3085
|
+
underlineColor
|
|
3086
|
+
});
|
|
3087
|
+
}
|
|
3088
|
+
setRowMeta(row, meta) {
|
|
3089
|
+
this.postStateOps.push({
|
|
3090
|
+
kind: "setRowMeta",
|
|
3091
|
+
row,
|
|
3092
|
+
...meta
|
|
3093
|
+
});
|
|
3094
|
+
}
|
|
3095
|
+
setOutlineSnapshots(snapshots) {
|
|
3096
|
+
this.postStateOps.push({
|
|
3097
|
+
kind: "setOutlineSnapshots",
|
|
3098
|
+
snapshots: snapshots.map((snapshot) => ({
|
|
3099
|
+
...snapshot,
|
|
3100
|
+
cell: cloneCell(snapshot.cell)
|
|
3101
|
+
}))
|
|
3102
|
+
});
|
|
3103
|
+
}
|
|
3104
|
+
selectableCell(cell, selectable) {
|
|
3105
|
+
const cloned = cloneCellPatch(cell);
|
|
3106
|
+
return selectable === void 0 ? cloned : {
|
|
3107
|
+
...cloned,
|
|
3108
|
+
selectable
|
|
3109
|
+
};
|
|
3110
|
+
}
|
|
3111
|
+
/**
|
|
3112
|
+
* Snapshot the accumulated emissions as an immutable
|
|
3113
|
+
* `SectionedRenderPlan`. After calling this, further emissions on the
|
|
3114
|
+
* sink do not affect the returned plan.
|
|
3115
|
+
*/
|
|
3116
|
+
toPlan() {
|
|
3117
|
+
return {
|
|
3118
|
+
width: this.width,
|
|
3119
|
+
height: this.height,
|
|
3120
|
+
transferOps: this.transferOps.slice(),
|
|
3121
|
+
cleanupOps: this.cleanupOps.slice(),
|
|
3122
|
+
paintOps: this.paintOps.slice(),
|
|
3123
|
+
overlayOps: this.overlayOps.slice(),
|
|
3124
|
+
postStateOps: this.postStateOps.slice()
|
|
3125
|
+
};
|
|
3126
|
+
}
|
|
3127
|
+
};
|
|
3128
|
+
//#endregion
|
|
1575
3129
|
//#region packages/ag-term/src/pipeline/render-text.ts
|
|
1576
3130
|
init_buffer();
|
|
1577
3131
|
const log$1 = createLogger("silvery:content");
|
|
@@ -1663,6 +3217,7 @@ function styleToAnsi(style) {
|
|
|
1663
3217
|
}
|
|
1664
3218
|
if (style.inverse) parts.push("7");
|
|
1665
3219
|
if (style.strikethrough) parts.push("9");
|
|
3220
|
+
if (style.overline) parts.push("53");
|
|
1666
3221
|
if (parts.length === 0) return "";
|
|
1667
3222
|
return `\x1b[${parts.join(";")}m`;
|
|
1668
3223
|
}
|
|
@@ -1671,15 +3226,31 @@ function styleToAnsi(style) {
|
|
|
1671
3226
|
* Child values override parent values when specified.
|
|
1672
3227
|
*/
|
|
1673
3228
|
function mergeStyleContext(parent, childProps) {
|
|
3229
|
+
const effectiveChildColor = childProps.color === "inherit" || childProps.color === "currentColor" ? parent.color : childProps.color;
|
|
3230
|
+
const childUl = childProps.underline;
|
|
3231
|
+
const childUlStyle = childProps.underlineStyle;
|
|
3232
|
+
let childUnderline;
|
|
3233
|
+
let childUnderlineStyle;
|
|
3234
|
+
if (childUlStyle !== void 0) {
|
|
3235
|
+
childUnderline = childUlStyle === false ? false : true;
|
|
3236
|
+
childUnderlineStyle = childUlStyle;
|
|
3237
|
+
} else if (typeof childUl === "string") {
|
|
3238
|
+
childUnderline = true;
|
|
3239
|
+
childUnderlineStyle = childUl;
|
|
3240
|
+
} else if (childUl !== void 0) {
|
|
3241
|
+
childUnderline = childUl;
|
|
3242
|
+
childUnderlineStyle = void 0;
|
|
3243
|
+
}
|
|
1674
3244
|
return {
|
|
1675
|
-
color:
|
|
3245
|
+
color: effectiveChildColor ?? parent.color,
|
|
1676
3246
|
backgroundColor: childProps.backgroundColor ?? parent.backgroundColor,
|
|
1677
3247
|
bold: childProps.bold ?? parent.bold,
|
|
1678
|
-
dim:
|
|
3248
|
+
dim: parent.dim,
|
|
1679
3249
|
italic: childProps.italic ?? parent.italic,
|
|
1680
|
-
underline:
|
|
1681
|
-
underlineStyle:
|
|
3250
|
+
underline: childUnderline ?? parent.underline,
|
|
3251
|
+
underlineStyle: childUnderlineStyle ?? parent.underlineStyle,
|
|
1682
3252
|
underlineColor: childProps.underlineColor ?? parent.underlineColor,
|
|
3253
|
+
overline: childProps.overline ?? parent.overline,
|
|
1683
3254
|
inverse: childProps.inverse ?? parent.inverse,
|
|
1684
3255
|
strikethrough: childProps.strikethrough ?? parent.strikethrough
|
|
1685
3256
|
};
|
|
@@ -1800,9 +3371,12 @@ function collectTextWithBg(node, parentContext = {}, offset = 0, maxDisplayWidth
|
|
|
1800
3371
|
* @param lineCharEnd - Character offset in original text where this line ends
|
|
1801
3372
|
* @param bgSegments - Background color segments to apply
|
|
1802
3373
|
*/
|
|
1803
|
-
function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEnd, bgSegments, ctx) {
|
|
3374
|
+
function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEnd, bgSegments, ctx, maxCol, minCol, selectable = false) {
|
|
1804
3375
|
if (bgSegments.length === 0) return;
|
|
1805
|
-
|
|
3376
|
+
const sink = createFrameSink(buffer);
|
|
3377
|
+
if (y < 0 || y >= sink.height) return;
|
|
3378
|
+
const rightClip = maxCol !== void 0 ? Math.min(maxCol, sink.width) : sink.width;
|
|
3379
|
+
const leftClip = minCol !== void 0 ? Math.max(minCol, 0) : 0;
|
|
1806
3380
|
const bgCell = createMutableCell();
|
|
1807
3381
|
const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth;
|
|
1808
3382
|
for (const seg of bgSegments) {
|
|
@@ -1818,17 +3392,20 @@ function applyBgSegmentsToLine(buffer, x, y, lineText, lineCharStart, lineCharEn
|
|
|
1818
3392
|
if (gWidth === 0) continue;
|
|
1819
3393
|
const displayOffset = col - x;
|
|
1820
3394
|
if (displayOffset >= relStart && displayOffset < relEnd) {
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
buffer.setCell(col, y, bgCell);
|
|
1824
|
-
if (gWidth === 2 && col + 1 < buffer.width) {
|
|
1825
|
-
buffer.readCellInto(col + 1, y, bgCell);
|
|
3395
|
+
if (col < rightClip && col >= leftClip) {
|
|
3396
|
+
buffer.readCellInto(col, y, bgCell);
|
|
1826
3397
|
bgCell.bg = seg.bg;
|
|
1827
|
-
|
|
3398
|
+
sink.emitSetCell(col, y, bgCell, selectable);
|
|
3399
|
+
if (gWidth === 2 && col + 1 < sink.width && col + 1 < rightClip) {
|
|
3400
|
+
buffer.readCellInto(col + 1, y, bgCell);
|
|
3401
|
+
bgCell.bg = seg.bg;
|
|
3402
|
+
sink.emitSetCell(col + 1, y, bgCell, selectable);
|
|
3403
|
+
}
|
|
1828
3404
|
}
|
|
1829
3405
|
}
|
|
1830
3406
|
col += gWidth;
|
|
1831
3407
|
if (col - x >= relEnd) break;
|
|
3408
|
+
if (col >= rightClip) break;
|
|
1832
3409
|
}
|
|
1833
3410
|
}
|
|
1834
3411
|
}
|
|
@@ -1949,6 +3526,10 @@ function formatTextLines(text, width, wrap, ctx, trim = true) {
|
|
|
1949
3526
|
if (wrap === "truncate-start") return lines.map((line) => truncateText(line, width, "start", ctx));
|
|
1950
3527
|
if (wrap === "truncate-middle") return lines.map((line) => truncateText(line, width, "middle", ctx));
|
|
1951
3528
|
if (wrap === "even") return optimalWrap(normalizedText, buildTextAnalysis(normalizedText, ctx?.measurer?.graphemeWidth?.bind(ctx.measurer) ?? graphemeWidth), width);
|
|
3529
|
+
if (wrap === "wrap-truncate") {
|
|
3530
|
+
if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, false, true);
|
|
3531
|
+
return wrapText(normalizedText, width, true, true, true);
|
|
3532
|
+
}
|
|
1952
3533
|
if (ctx) return ctx.measurer.wrapText(normalizedText, width, true, trim);
|
|
1953
3534
|
return wrapText(normalizedText, width, true, trim);
|
|
1954
3535
|
}
|
|
@@ -1979,20 +3560,20 @@ function truncateText(text, width, mode, ctx) {
|
|
|
1979
3560
|
* become stale during incremental rendering (the owning container's dirty
|
|
1980
3561
|
* tracking doesn't cover cells outside its layout bounds).
|
|
1981
3562
|
*/
|
|
1982
|
-
function renderTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx) {
|
|
3563
|
+
function renderTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, selectable = false) {
|
|
1983
3564
|
if (hasAnsi(text)) {
|
|
1984
|
-
renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx);
|
|
3565
|
+
renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, selectable);
|
|
1985
3566
|
return;
|
|
1986
3567
|
}
|
|
1987
|
-
renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx);
|
|
3568
|
+
renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, void 0, selectable);
|
|
1988
3569
|
}
|
|
1989
3570
|
/**
|
|
1990
3571
|
* Like renderTextLine but returns the column position after the last rendered character.
|
|
1991
3572
|
* Used by renderText to know where to clear remaining cells.
|
|
1992
3573
|
*/
|
|
1993
|
-
function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol) {
|
|
1994
|
-
if (hasAnsi(text)) return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol);
|
|
1995
|
-
return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, minCol);
|
|
3574
|
+
function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable = false, bgConflictOverride) {
|
|
3575
|
+
if (hasAnsi(text)) return renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable, bgConflictOverride);
|
|
3576
|
+
return renderGraphemes(buffer, splitGraphemes(text), x, y, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable);
|
|
1996
3577
|
}
|
|
1997
3578
|
/**
|
|
1998
3579
|
* Render graphemes to buffer cells with proper Unicode handling.
|
|
@@ -2011,9 +3592,10 @@ function renderTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg
|
|
|
2011
3592
|
*
|
|
2012
3593
|
* Returns the column position after the last rendered grapheme.
|
|
2013
3594
|
*/
|
|
2014
|
-
function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inheritedBg, ctx, minCol) {
|
|
3595
|
+
function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inheritedBg, ctx, minCol, selectable = false) {
|
|
3596
|
+
const sink = createFrameSink(buffer);
|
|
2015
3597
|
let col = startCol;
|
|
2016
|
-
const rightEdge = maxCol !== void 0 ? Math.min(maxCol,
|
|
3598
|
+
const rightEdge = maxCol !== void 0 ? Math.min(maxCol, sink.width) : sink.width;
|
|
2017
3599
|
const leftEdge = minCol !== void 0 ? Math.max(minCol, 0) : 0;
|
|
2018
3600
|
const gWidthFn = ctx ? ctx.measurer.graphemeWidth : graphemeWidth;
|
|
2019
3601
|
for (const grapheme of graphemes) {
|
|
@@ -2028,9 +3610,9 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
|
|
|
2028
3610
|
col = leftEdge;
|
|
2029
3611
|
continue;
|
|
2030
3612
|
}
|
|
2031
|
-
const existingBg = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg :
|
|
3613
|
+
const existingBg = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : null;
|
|
2032
3614
|
if (width === 2 && col + 1 >= rightEdge) {
|
|
2033
|
-
|
|
3615
|
+
sink.emitSetCell(col, y, {
|
|
2034
3616
|
char: " ",
|
|
2035
3617
|
fg: style.fg,
|
|
2036
3618
|
bg: existingBg,
|
|
@@ -2039,12 +3621,12 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
|
|
|
2039
3621
|
wide: false,
|
|
2040
3622
|
continuation: false,
|
|
2041
3623
|
hyperlink: style.hyperlink
|
|
2042
|
-
});
|
|
3624
|
+
}, false);
|
|
2043
3625
|
col += 1;
|
|
2044
3626
|
continue;
|
|
2045
3627
|
}
|
|
2046
3628
|
const outputChar = width === 2 ? ensureEmojiPresentation(grapheme) : grapheme;
|
|
2047
|
-
|
|
3629
|
+
sink.emitSetCell(col, y, {
|
|
2048
3630
|
char: outputChar,
|
|
2049
3631
|
fg: style.fg,
|
|
2050
3632
|
bg: existingBg,
|
|
@@ -2053,10 +3635,10 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
|
|
|
2053
3635
|
wide: width === 2,
|
|
2054
3636
|
continuation: false,
|
|
2055
3637
|
hyperlink: style.hyperlink
|
|
2056
|
-
});
|
|
2057
|
-
if (width === 2 && col + 1 <
|
|
2058
|
-
const existingBg2 = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg :
|
|
2059
|
-
|
|
3638
|
+
}, selectable);
|
|
3639
|
+
if (width === 2 && col + 1 < sink.width) {
|
|
3640
|
+
const existingBg2 = style.bg !== null ? style.bg : inheritedBg !== void 0 ? inheritedBg : null;
|
|
3641
|
+
sink.emitSetCell(col + 1, y, {
|
|
2060
3642
|
char: "",
|
|
2061
3643
|
fg: style.fg,
|
|
2062
3644
|
bg: existingBg2,
|
|
@@ -2065,7 +3647,7 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
|
|
|
2065
3647
|
wide: false,
|
|
2066
3648
|
continuation: true,
|
|
2067
3649
|
hyperlink: style.hyperlink
|
|
2068
|
-
});
|
|
3650
|
+
}, selectable);
|
|
2069
3651
|
col += 2;
|
|
2070
3652
|
} else col += width;
|
|
2071
3653
|
}
|
|
@@ -2075,20 +3657,21 @@ function renderGraphemes(buffer, graphemes, startCol, y, style, maxCol, inherite
|
|
|
2075
3657
|
* Render text line with ANSI escape sequences.
|
|
2076
3658
|
* Parses ANSI codes and applies styles to individual segments.
|
|
2077
3659
|
*/
|
|
2078
|
-
function renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx) {
|
|
2079
|
-
renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx);
|
|
3660
|
+
function renderAnsiTextLine(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, selectable = false) {
|
|
3661
|
+
renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, void 0, selectable);
|
|
2080
3662
|
}
|
|
2081
3663
|
/**
|
|
2082
3664
|
* Like renderAnsiTextLine but returns the column position after the last rendered character.
|
|
2083
3665
|
*/
|
|
2084
|
-
function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol) {
|
|
3666
|
+
function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inheritedBg, ctx, minCol, selectable = false, bgConflictOverride) {
|
|
3667
|
+
createFrameSink(buffer);
|
|
2085
3668
|
const segments = parseAnsiText(text);
|
|
2086
3669
|
let col = x;
|
|
2087
3670
|
for (const segment of segments) {
|
|
2088
3671
|
const style = mergeAnsiStyle(baseStyle, segment);
|
|
2089
|
-
const effectiveBgConflictMode = ctx?.bgConflictMode ?? getBgConflictMode();
|
|
3672
|
+
const effectiveBgConflictMode = bgConflictOverride ?? ctx?.bgConflictMode ?? getBgConflictMode();
|
|
2090
3673
|
if (effectiveBgConflictMode !== "ignore" && !segment.bgOverride && segment.bg !== void 0 && segment.bg !== null) {
|
|
2091
|
-
const existingBufBg =
|
|
3674
|
+
const existingBufBg = inheritedBg !== void 0 ? inheritedBg : null;
|
|
2092
3675
|
if (baseStyle.bg !== null || existingBufBg !== null) {
|
|
2093
3676
|
const preview = segment.text.slice(0, 30);
|
|
2094
3677
|
const chalkBg = formatBgConflictColor(segment.bg);
|
|
@@ -2104,7 +3687,7 @@ function renderAnsiTextLineReturn(buffer, x, y, text, baseStyle, maxCol, inherit
|
|
|
2104
3687
|
}
|
|
2105
3688
|
}
|
|
2106
3689
|
}
|
|
2107
|
-
col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx, minCol);
|
|
3690
|
+
col = renderGraphemes(buffer, splitGraphemes(segment.text), col, y, style, maxCol, inheritedBg, ctx, minCol, selectable);
|
|
2108
3691
|
}
|
|
2109
3692
|
return col;
|
|
2110
3693
|
}
|
|
@@ -2135,10 +3718,12 @@ function mergeStyles(base, overlay, options = {}) {
|
|
|
2135
3718
|
attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle ?? "single";
|
|
2136
3719
|
}
|
|
2137
3720
|
attrs.strikethrough = overlayAttrs.strikethrough || baseAttrs.strikethrough;
|
|
3721
|
+
attrs.overline = overlayAttrs.overline || baseAttrs.overline;
|
|
2138
3722
|
} else {
|
|
2139
3723
|
attrs.underline = overlayAttrs.underline ?? baseAttrs.underline;
|
|
2140
3724
|
attrs.underlineStyle = overlayAttrs.underlineStyle ?? baseAttrs.underlineStyle;
|
|
2141
3725
|
attrs.strikethrough = overlayAttrs.strikethrough ?? baseAttrs.strikethrough;
|
|
3726
|
+
attrs.overline = overlayAttrs.overline ?? baseAttrs.overline;
|
|
2142
3727
|
}
|
|
2143
3728
|
if (preserveEmphasis) {
|
|
2144
3729
|
attrs.bold = overlayAttrs.bold || baseAttrs.bold;
|
|
@@ -2178,6 +3763,8 @@ function mergeAnsiStyle(base, segment, options = {}) {
|
|
|
2178
3763
|
if (segment.underline !== void 0) overlayAttrs.underline = segment.underline;
|
|
2179
3764
|
if (segment.underlineStyle !== void 0) overlayAttrs.underlineStyle = segment.underlineStyle;
|
|
2180
3765
|
if (segment.inverse !== void 0) overlayAttrs.inverse = segment.inverse;
|
|
3766
|
+
if (segment.strikethrough !== void 0) overlayAttrs.strikethrough = segment.strikethrough;
|
|
3767
|
+
if (segment.overline !== void 0) overlayAttrs.overline = segment.overline;
|
|
2181
3768
|
const merged = mergeStyles(base, {
|
|
2182
3769
|
fg,
|
|
2183
3770
|
bg,
|
|
@@ -2215,6 +3802,7 @@ function ansiColorToColor(code) {
|
|
|
2215
3802
|
* See km-silvery.bg-bleed for details.
|
|
2216
3803
|
*/
|
|
2217
3804
|
function renderText(node, buffer, layout, props, nodeState, inheritedBg, inheritedFg, ctx) {
|
|
3805
|
+
const sink = createFrameSink(buffer);
|
|
2218
3806
|
const { scrollOffset, clipBounds } = nodeState;
|
|
2219
3807
|
const { x, width, height } = layout;
|
|
2220
3808
|
let { y } = layout;
|
|
@@ -2236,20 +3824,29 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
|
|
|
2236
3824
|
lineCount = (plainText.match(/\n/g)?.length ?? 0) + 1;
|
|
2237
3825
|
setCachedPlainText(node, plainText, lineCount);
|
|
2238
3826
|
}
|
|
2239
|
-
maxDisplayWidth = (width +
|
|
3827
|
+
maxDisplayWidth = (width + 2) * lineCount;
|
|
2240
3828
|
}
|
|
2241
3829
|
let text;
|
|
2242
3830
|
let bgSegments;
|
|
2243
3831
|
let childSpans;
|
|
3832
|
+
let rootUnderline;
|
|
3833
|
+
let rootUnderlineStyle;
|
|
3834
|
+
if (props.underlineStyle !== void 0) {
|
|
3835
|
+
rootUnderline = props.underlineStyle !== false;
|
|
3836
|
+
rootUnderlineStyle = props.underlineStyle;
|
|
3837
|
+
} else if (typeof props.underline === "string") {
|
|
3838
|
+
rootUnderline = true;
|
|
3839
|
+
rootUnderlineStyle = props.underline;
|
|
3840
|
+
} else if (props.underline !== void 0) rootUnderline = props.underline;
|
|
2244
3841
|
const rootContext = {
|
|
2245
3842
|
color: props.color,
|
|
2246
3843
|
backgroundColor: props.backgroundColor,
|
|
2247
3844
|
bold: props.bold,
|
|
2248
|
-
dim: props.dim || props.dimColor,
|
|
2249
3845
|
italic: props.italic,
|
|
2250
|
-
underline:
|
|
2251
|
-
underlineStyle:
|
|
3846
|
+
underline: rootUnderline,
|
|
3847
|
+
underlineStyle: rootUnderlineStyle,
|
|
2252
3848
|
underlineColor: props.underlineColor,
|
|
3849
|
+
overline: props.overline,
|
|
2253
3850
|
inverse: props.inverse,
|
|
2254
3851
|
strikethrough: props.strikethrough
|
|
2255
3852
|
};
|
|
@@ -2288,14 +3885,16 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
|
|
|
2288
3885
|
const lineY = y + lineIdx;
|
|
2289
3886
|
if (clipBounds && (lineY < clipBounds.top || lineY >= clipBounds.bottom)) continue;
|
|
2290
3887
|
const line = lines[lineIdx];
|
|
2291
|
-
const layoutRight = internalTransform ?
|
|
3888
|
+
const layoutRight = internalTransform ? sink.width : x + width;
|
|
2292
3889
|
const maxCol = clipBounds && "right" in clipBounds && clipBounds.right !== void 0 ? Math.min(layoutRight, clipBounds.right) : layoutRight;
|
|
2293
3890
|
const minCol = clipBounds && "left" in clipBounds && clipBounds.left !== void 0 ? clipBounds.left : void 0;
|
|
2294
|
-
const
|
|
3891
|
+
const lineTextForSelection = hasAnsi(line) ? stripAnsiForBg(line) : line;
|
|
3892
|
+
const lineSelectable = nodeState.selectableMode && /\S/.test(lineTextForSelection);
|
|
3893
|
+
const endCol = renderTextLineReturn(buffer, x, lineY, line, style, maxCol, inheritedBg, ctx, minCol, lineSelectable, props.bgConflict);
|
|
2295
3894
|
const clearStart = minCol !== void 0 ? Math.max(endCol, minCol) : endCol;
|
|
2296
3895
|
if (clearStart < maxCol) {
|
|
2297
3896
|
const clearBg = inheritedBg ?? null;
|
|
2298
|
-
|
|
3897
|
+
const clearCell = {
|
|
2299
3898
|
char: " ",
|
|
2300
3899
|
fg: style.fg,
|
|
2301
3900
|
bg: clearBg,
|
|
@@ -2305,6 +3904,7 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
|
|
|
2305
3904
|
dim: false,
|
|
2306
3905
|
italic: false,
|
|
2307
3906
|
underline: false,
|
|
3907
|
+
overline: false,
|
|
2308
3908
|
inverse: false,
|
|
2309
3909
|
strikethrough: false,
|
|
2310
3910
|
blink: false,
|
|
@@ -2312,11 +3912,12 @@ function renderText(node, buffer, layout, props, nodeState, inheritedBg, inherit
|
|
|
2312
3912
|
},
|
|
2313
3913
|
wide: false,
|
|
2314
3914
|
continuation: false
|
|
2315
|
-
}
|
|
3915
|
+
};
|
|
3916
|
+
for (let cx = clearStart; cx < maxCol && cx < sink.width; cx++) sink.emitClearCells(cx, lineY, 1, 1, clearCell);
|
|
2316
3917
|
}
|
|
2317
3918
|
if (bgSegments.length > 0 && lineIdx < lineOffsets.length) {
|
|
2318
3919
|
const { start, end } = lineOffsets[lineIdx];
|
|
2319
|
-
applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx);
|
|
3920
|
+
applyBgSegmentsToLine(buffer, x, lineY, line, start, end, bgSegments, ctx, maxCol, minCol, lineSelectable);
|
|
2320
3921
|
}
|
|
2321
3922
|
}
|
|
2322
3923
|
if (childSpans.length > 0 && lineOffsets.length > 0) computeInlineRects(childSpans, lineOffsets, x, y, lines.length, height);
|
|
@@ -2378,6 +3979,7 @@ function getEffectiveBg(props) {
|
|
|
2378
3979
|
* Render a Box node.
|
|
2379
3980
|
*/
|
|
2380
3981
|
function renderBox(_node, buffer, layout, props, nodeState, skipBgFill = false, inheritedBg, bgOnlyChange = false, inheritedFg) {
|
|
3982
|
+
const sink = createFrameSink(buffer);
|
|
2381
3983
|
const { scrollOffset, clipBounds } = nodeState;
|
|
2382
3984
|
const { x, width, height } = layout;
|
|
2383
3985
|
const y = layout.y - scrollOffset;
|
|
@@ -2399,17 +4001,17 @@ function renderBox(_node, buffer, layout, props, nodeState, skipBgFill = false,
|
|
|
2399
4001
|
clippedX = Math.max(x, clipBounds.left);
|
|
2400
4002
|
clippedWidth = Math.min(x + width, clipBounds.right) - clippedX;
|
|
2401
4003
|
}
|
|
2402
|
-
if (clippedHeight > 0 && clippedWidth > 0) if (bgOnlyChange)
|
|
2403
|
-
else
|
|
2404
|
-
} else if (bgOnlyChange)
|
|
2405
|
-
else
|
|
4004
|
+
if (clippedHeight > 0 && clippedWidth > 0) if (bgOnlyChange) sink.emitFillBg(clippedX, clippedY, clippedWidth, clippedHeight, bg);
|
|
4005
|
+
else sink.emitPaintFill(clippedX, clippedY, clippedWidth, clippedHeight, { bg });
|
|
4006
|
+
} else if (bgOnlyChange) sink.emitFillBg(x, y, width, height, bg);
|
|
4007
|
+
else sink.emitPaintFill(x, y, width, height, { bg });
|
|
2406
4008
|
}
|
|
2407
|
-
if (props.borderStyle) renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg);
|
|
4009
|
+
if (props.borderStyle) renderBorder(buffer, sink, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg);
|
|
2408
4010
|
}
|
|
2409
4011
|
/**
|
|
2410
4012
|
* Render a border around a box.
|
|
2411
4013
|
*/
|
|
2412
|
-
function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg) {
|
|
4014
|
+
function renderBorder(buffer, sink, x, y, width, height, props, clipBounds, inheritedBg, inheritedFg) {
|
|
2413
4015
|
const chars = getBorderChars(props.borderStyle ?? "single");
|
|
2414
4016
|
let color;
|
|
2415
4017
|
if (props.borderColor === "currentColor" || props.borderColor === "inherit") color = props.color ? parseColor(props.color) : inheritedFg ?? null;
|
|
@@ -2430,27 +4032,27 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
|
|
|
2430
4032
|
const showLeft = props.borderLeft !== false;
|
|
2431
4033
|
const showRight = props.borderRight !== false;
|
|
2432
4034
|
const isRowVisible = (row) => {
|
|
2433
|
-
if (!clipBounds) return row >= 0 && row <
|
|
2434
|
-
return row >= clipBounds.top && row < clipBounds.bottom && row <
|
|
4035
|
+
if (!clipBounds) return row >= 0 && row < sink.height;
|
|
4036
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < sink.height;
|
|
2435
4037
|
};
|
|
2436
4038
|
const isColVisible = (col) => {
|
|
2437
|
-
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col <
|
|
2438
|
-
return col >= clipBounds.left && col < clipBounds.right && col <
|
|
4039
|
+
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < sink.width;
|
|
4040
|
+
return col >= clipBounds.left && col < clipBounds.right && col < sink.width;
|
|
2439
4041
|
};
|
|
2440
4042
|
if (showTop && isRowVisible(y)) {
|
|
2441
|
-
if (showLeft && isColVisible(x))
|
|
4043
|
+
if (showLeft && isColVisible(x)) sink.emitSetCell(x, y, {
|
|
2442
4044
|
char: chars.topLeft,
|
|
2443
4045
|
fg: color,
|
|
2444
4046
|
bg: topBg
|
|
2445
4047
|
});
|
|
2446
4048
|
const hStart = showLeft ? x + 1 : x;
|
|
2447
4049
|
const hEnd = showRight ? x + width - 1 : x + width;
|
|
2448
|
-
for (let col = hStart; col < hEnd && col <
|
|
4050
|
+
for (let col = hStart; col < hEnd && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, y, {
|
|
2449
4051
|
char: chars.horizontal,
|
|
2450
4052
|
fg: color,
|
|
2451
4053
|
bg: topBg
|
|
2452
4054
|
});
|
|
2453
|
-
if (showRight && x + width - 1 <
|
|
4055
|
+
if (showRight && x + width - 1 < sink.width && isColVisible(x + width - 1)) sink.emitSetCell(x + width - 1, y, {
|
|
2454
4056
|
char: chars.topRight,
|
|
2455
4057
|
fg: color,
|
|
2456
4058
|
bg: topBg
|
|
@@ -2461,12 +4063,12 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
|
|
|
2461
4063
|
const sideEnd = showBottom ? y + height - 1 : y + height;
|
|
2462
4064
|
for (let row = sideStart; row < sideEnd; row++) {
|
|
2463
4065
|
if (!isRowVisible(row)) continue;
|
|
2464
|
-
if (showLeft && isColVisible(x))
|
|
4066
|
+
if (showLeft && isColVisible(x)) sink.emitSetCell(x, row, {
|
|
2465
4067
|
char: chars.vertical,
|
|
2466
4068
|
fg: color,
|
|
2467
4069
|
bg: leftBg
|
|
2468
4070
|
});
|
|
2469
|
-
if (showRight && x + width - 1 <
|
|
4071
|
+
if (showRight && x + width - 1 < sink.width && isColVisible(x + width - 1)) sink.emitSetCell(x + width - 1, row, {
|
|
2470
4072
|
char: rightVertical,
|
|
2471
4073
|
fg: color,
|
|
2472
4074
|
bg: rightBg
|
|
@@ -2475,19 +4077,19 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
|
|
|
2475
4077
|
const bottomHorizontal = chars.bottomHorizontal ?? chars.horizontal;
|
|
2476
4078
|
const bottomY = y + height - 1;
|
|
2477
4079
|
if (showBottom && isRowVisible(bottomY)) {
|
|
2478
|
-
if (showLeft && isColVisible(x))
|
|
4080
|
+
if (showLeft && isColVisible(x)) sink.emitSetCell(x, bottomY, {
|
|
2479
4081
|
char: chars.bottomLeft,
|
|
2480
4082
|
fg: color,
|
|
2481
4083
|
bg: bottomBg
|
|
2482
4084
|
});
|
|
2483
4085
|
const bStart = showLeft ? x + 1 : x;
|
|
2484
4086
|
const bEnd = showRight ? x + width - 1 : x + width;
|
|
2485
|
-
for (let col = bStart; col < bEnd && col <
|
|
4087
|
+
for (let col = bStart; col < bEnd && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, bottomY, {
|
|
2486
4088
|
char: bottomHorizontal,
|
|
2487
4089
|
fg: color,
|
|
2488
4090
|
bg: bottomBg
|
|
2489
4091
|
});
|
|
2490
|
-
if (showRight && x + width - 1 <
|
|
4092
|
+
if (showRight && x + width - 1 < sink.width && isColVisible(x + width - 1)) sink.emitSetCell(x + width - 1, bottomY, {
|
|
2491
4093
|
char: chars.bottomRight,
|
|
2492
4094
|
fg: color,
|
|
2493
4095
|
bg: bottomBg
|
|
@@ -2504,7 +4106,7 @@ function renderBorder(buffer, x, y, width, height, props, clipBounds, inheritedB
|
|
|
2504
4106
|
* The outline occupies cells at (x-1, y-1) through (x+width, y+height) —
|
|
2505
4107
|
* entirely outside the box's own rect. Content is never overlapped.
|
|
2506
4108
|
*/
|
|
2507
|
-
function renderOutline(buffer, x, y, width, height, props, clipBounds, inheritedBg) {
|
|
4109
|
+
function renderOutline(buffer, sink, x, y, width, height, props, clipBounds, inheritedBg) {
|
|
2508
4110
|
const chars = getBorderChars(props.outlineStyle ?? "single");
|
|
2509
4111
|
const color = props.outlineColor ? parseColor(props.outlineColor) : null;
|
|
2510
4112
|
const bg = props.backgroundColor ? parseColor(props.backgroundColor) : inheritedBg ?? null;
|
|
@@ -2514,31 +4116,31 @@ function renderOutline(buffer, x, y, width, height, props, clipBounds, inherited
|
|
|
2514
4116
|
const ow = width + 2;
|
|
2515
4117
|
const oh = height + 2;
|
|
2516
4118
|
const isRowVisible = (row) => {
|
|
2517
|
-
if (!clipBounds) return row >= 0 && row <
|
|
2518
|
-
return row >= clipBounds.top && row < clipBounds.bottom && row <
|
|
4119
|
+
if (!clipBounds) return row >= 0 && row < sink.height;
|
|
4120
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < sink.height;
|
|
2519
4121
|
};
|
|
2520
4122
|
const isColVisible = (col) => {
|
|
2521
|
-
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col <
|
|
2522
|
-
return col >= clipBounds.left && col < clipBounds.right && col <
|
|
4123
|
+
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < sink.width;
|
|
4124
|
+
return col >= clipBounds.left && col < clipBounds.right && col < sink.width;
|
|
2523
4125
|
};
|
|
2524
4126
|
const showTop = props.outlineTop !== false;
|
|
2525
4127
|
const showBottom = props.outlineBottom !== false;
|
|
2526
4128
|
const showLeft = props.outlineLeft !== false;
|
|
2527
4129
|
const showRight = props.outlineRight !== false;
|
|
2528
4130
|
if (showTop && isRowVisible(oy)) {
|
|
2529
|
-
if (showLeft && isColVisible(ox))
|
|
4131
|
+
if (showLeft && isColVisible(ox)) sink.emitSetCell(ox, oy, {
|
|
2530
4132
|
char: chars.topLeft,
|
|
2531
4133
|
fg: color,
|
|
2532
4134
|
bg,
|
|
2533
4135
|
attrs
|
|
2534
4136
|
});
|
|
2535
|
-
for (let col = ox + 1; col < ox + ow - 1 && col <
|
|
4137
|
+
for (let col = ox + 1; col < ox + ow - 1 && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, oy, {
|
|
2536
4138
|
char: chars.horizontal,
|
|
2537
4139
|
fg: color,
|
|
2538
4140
|
bg,
|
|
2539
4141
|
attrs
|
|
2540
4142
|
});
|
|
2541
|
-
if (showRight && ox + ow - 1 <
|
|
4143
|
+
if (showRight && ox + ow - 1 < sink.width && isColVisible(ox + ow - 1)) sink.emitSetCell(ox + ow - 1, oy, {
|
|
2542
4144
|
char: chars.topRight,
|
|
2543
4145
|
fg: color,
|
|
2544
4146
|
bg,
|
|
@@ -2550,13 +4152,13 @@ function renderOutline(buffer, x, y, width, height, props, clipBounds, inherited
|
|
|
2550
4152
|
const sideEnd = showBottom ? oy + oh - 1 : oy + oh;
|
|
2551
4153
|
for (let row = sideStart; row < sideEnd; row++) {
|
|
2552
4154
|
if (!isRowVisible(row)) continue;
|
|
2553
|
-
if (showLeft && isColVisible(ox))
|
|
4155
|
+
if (showLeft && isColVisible(ox)) sink.emitSetCell(ox, row, {
|
|
2554
4156
|
char: chars.vertical,
|
|
2555
4157
|
fg: color,
|
|
2556
4158
|
bg,
|
|
2557
4159
|
attrs
|
|
2558
4160
|
});
|
|
2559
|
-
if (showRight && ox + ow - 1 <
|
|
4161
|
+
if (showRight && ox + ow - 1 < sink.width && isColVisible(ox + ow - 1)) sink.emitSetCell(ox + ow - 1, row, {
|
|
2560
4162
|
char: outlineRightVertical,
|
|
2561
4163
|
fg: color,
|
|
2562
4164
|
bg,
|
|
@@ -2566,19 +4168,19 @@ function renderOutline(buffer, x, y, width, height, props, clipBounds, inherited
|
|
|
2566
4168
|
const outlineBottomHorizontal = chars.bottomHorizontal ?? chars.horizontal;
|
|
2567
4169
|
const bottomY = oy + oh - 1;
|
|
2568
4170
|
if (showBottom && isRowVisible(bottomY)) {
|
|
2569
|
-
if (showLeft && isColVisible(ox))
|
|
4171
|
+
if (showLeft && isColVisible(ox)) sink.emitSetCell(ox, bottomY, {
|
|
2570
4172
|
char: chars.bottomLeft,
|
|
2571
4173
|
fg: color,
|
|
2572
4174
|
bg,
|
|
2573
4175
|
attrs
|
|
2574
4176
|
});
|
|
2575
|
-
for (let col = ox + 1; col < ox + ow - 1 && col <
|
|
4177
|
+
for (let col = ox + 1; col < ox + ow - 1 && col < sink.width; col++) if (isColVisible(col)) sink.emitSetCell(col, bottomY, {
|
|
2576
4178
|
char: outlineBottomHorizontal,
|
|
2577
4179
|
fg: color,
|
|
2578
4180
|
bg,
|
|
2579
4181
|
attrs
|
|
2580
4182
|
});
|
|
2581
|
-
if (showRight && ox + ow - 1 <
|
|
4183
|
+
if (showRight && ox + ow - 1 < sink.width && isColVisible(ox + ow - 1)) sink.emitSetCell(ox + ow - 1, bottomY, {
|
|
2582
4184
|
char: chars.bottomRight,
|
|
2583
4185
|
fg: color,
|
|
2584
4186
|
bg,
|
|
@@ -2613,43 +4215,277 @@ function renderScrollIndicators(_node, buffer, layout, props, ss, ctx) {
|
|
|
2613
4215
|
const indicator = `\u25b2${ss.hiddenAbove}`;
|
|
2614
4216
|
if (border.top > 0) {
|
|
2615
4217
|
const contentWidth = layout.width - border.left - border.right;
|
|
2616
|
-
const bar = padCenter(indicator, contentWidth);
|
|
2617
4218
|
const x = layout.x + border.left;
|
|
2618
4219
|
const y = layout.y;
|
|
2619
|
-
|
|
4220
|
+
renderCenteredIndicator(buffer, x, y, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
|
|
2620
4221
|
} else if (showBorderless) {
|
|
2621
4222
|
const padding = getPadding(props);
|
|
2622
4223
|
const contentWidth = layout.width - padding.left - padding.right;
|
|
2623
|
-
const bar = padCenter(indicator, contentWidth);
|
|
2624
4224
|
const x = layout.x + padding.left;
|
|
2625
|
-
|
|
4225
|
+
renderCenteredIndicator(buffer, x, layout.y + padding.top, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
|
|
2626
4226
|
}
|
|
2627
4227
|
}
|
|
2628
4228
|
if (ss.hiddenBelow > 0) {
|
|
2629
4229
|
const indicator = `\u25bc${ss.hiddenBelow}`;
|
|
2630
4230
|
if (border.bottom > 0) {
|
|
2631
4231
|
const contentWidth = layout.width - border.left - border.right;
|
|
2632
|
-
const bar = padCenter(indicator, contentWidth);
|
|
2633
4232
|
const x = layout.x + border.left;
|
|
2634
|
-
|
|
4233
|
+
renderCenteredIndicator(buffer, x, layout.y + layout.height - 1, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
|
|
2635
4234
|
} else if (showBorderless) {
|
|
2636
4235
|
const padding = getPadding(props);
|
|
2637
4236
|
const contentWidth = layout.width - padding.left - padding.right;
|
|
2638
|
-
const bar = padCenter(indicator, contentWidth);
|
|
2639
4237
|
const x = layout.x + padding.left;
|
|
2640
|
-
|
|
4238
|
+
renderCenteredIndicator(buffer, x, layout.y + layout.height - padding.bottom - 1, indicator, indicatorStyle, contentWidth, x + contentWidth, ctx);
|
|
2641
4239
|
}
|
|
2642
4240
|
}
|
|
2643
4241
|
}
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
|
|
2651
|
-
|
|
2652
|
-
|
|
4242
|
+
function renderCenteredIndicator(buffer, x, y, indicator, style, width, maxCol, ctx) {
|
|
4243
|
+
if (width <= 0) return;
|
|
4244
|
+
const text = indicator.length > width ? indicator.slice(0, width) : indicator;
|
|
4245
|
+
const indicatorX = x + Math.max(0, Math.floor((width - text.length) / 2));
|
|
4246
|
+
renderTextLine(buffer, x, y, " ".repeat(width), {
|
|
4247
|
+
fg: null,
|
|
4248
|
+
bg: null,
|
|
4249
|
+
attrs: {}
|
|
4250
|
+
}, maxCol, void 0, ctx);
|
|
4251
|
+
renderTextLine(buffer, indicatorX, y, text, style, Math.min(maxCol, indicatorX + text.length), void 0, ctx);
|
|
4252
|
+
}
|
|
4253
|
+
//#endregion
|
|
4254
|
+
//#region packages/ag-term/src/strict-island.ts
|
|
4255
|
+
/**
|
|
4256
|
+
* Runtime checks for `<Island>` strict slugs.
|
|
4257
|
+
*
|
|
4258
|
+
* The Island contract lives in `@silvery/ag`, but SILVERY_STRICT is a
|
|
4259
|
+
* terminal-target runtime gate. Keep the instrumentation here so core remains
|
|
4260
|
+
* target-agnostic while the terminal renderer can still enforce the contract.
|
|
4261
|
+
*/
|
|
4262
|
+
/** Tier-2 paint budget: one 256x256 island frame. */
|
|
4263
|
+
const ISLAND_PAINT_BUDGET_CELLS = 65536;
|
|
4264
|
+
const instrumentedHandles = /* @__PURE__ */ new WeakSet();
|
|
4265
|
+
/**
|
|
4266
|
+
* Attach strict wrappers to a live island handle once.
|
|
4267
|
+
*
|
|
4268
|
+
* This is called from the render phase when the terminal target first sees a
|
|
4269
|
+
* handle. Wrapping at the target avoids pulling SILVERY_STRICT into `@silvery/ag`.
|
|
4270
|
+
*/
|
|
4271
|
+
function ensureIslandStrictInstrumentation(node) {
|
|
4272
|
+
if (!needsIslandStrictInstrumentation()) return;
|
|
4273
|
+
const state = node.islandState;
|
|
4274
|
+
const handle = state?.handle;
|
|
4275
|
+
if (!state || !handle || instrumentedHandles.has(handle)) return;
|
|
4276
|
+
instrumentedHandles.add(handle);
|
|
4277
|
+
wrapWriteCells(handle);
|
|
4278
|
+
wrapSubscription(handle.output, state, "output");
|
|
4279
|
+
wrapSubscription(handle.size, state, "size");
|
|
4280
|
+
if (handle.modes) wrapSubscription(handle.modes, state, "modes");
|
|
4281
|
+
if (handle.input) wrapMouseInput(handle.input, handle);
|
|
4282
|
+
}
|
|
4283
|
+
function needsIslandStrictInstrumentation() {
|
|
4284
|
+
return isStrictEnabled("island-paint-oob", 2) || isStrictEnabled("island-dispose-leak", 2) || isStrictEnabled("island-boundary-limits", 2);
|
|
4285
|
+
}
|
|
4286
|
+
/** Render-phase checks that do not depend on guest mutation APIs. */
|
|
4287
|
+
function assertIslandRenderInvariants(node, layout) {
|
|
4288
|
+
const state = node.islandState;
|
|
4289
|
+
const handle = state?.handle;
|
|
4290
|
+
if (!state || !handle) return;
|
|
4291
|
+
const source = handle.output.buffer;
|
|
4292
|
+
assertResizeRace(handle, source);
|
|
4293
|
+
assertPaintBudget(source, layout);
|
|
4294
|
+
assertGraphemeWidth(source);
|
|
4295
|
+
assertCursorBoundary(handle);
|
|
4296
|
+
assertNoDisposedRender(state);
|
|
4297
|
+
}
|
|
4298
|
+
function wrapWriteCells(handle) {
|
|
4299
|
+
const original = handle.output.writeCells.bind(handle.output);
|
|
4300
|
+
handle.output.writeCells = (dirtyRects, source) => {
|
|
4301
|
+
assertDirtyRectsInsideIsland(handle, dirtyRects, source);
|
|
4302
|
+
return original(dirtyRects, source);
|
|
4303
|
+
};
|
|
4304
|
+
}
|
|
4305
|
+
function wrapSubscription(owner, state, label) {
|
|
4306
|
+
const original = owner.subscribe.bind(owner);
|
|
4307
|
+
owner.subscribe = ((listener) => {
|
|
4308
|
+
return original((...args) => {
|
|
4309
|
+
assertNoPostDisposeCallback(state, label);
|
|
4310
|
+
listener(...args);
|
|
4311
|
+
});
|
|
4312
|
+
});
|
|
4313
|
+
}
|
|
4314
|
+
function wrapMouseInput(input, handle) {
|
|
4315
|
+
if (!input.onMouse) return;
|
|
4316
|
+
const original = input.onMouse.bind(input);
|
|
4317
|
+
input.onMouse = (handler) => {
|
|
4318
|
+
return original((event) => {
|
|
4319
|
+
assertMouseInsideIsland(handle, event);
|
|
4320
|
+
handler(event);
|
|
4321
|
+
});
|
|
4322
|
+
};
|
|
4323
|
+
}
|
|
4324
|
+
function assertDirtyRectsInsideIsland(handle, dirtyRects, source) {
|
|
4325
|
+
if (!isStrictEnabled("island-paint-oob", 2)) return;
|
|
4326
|
+
const cols = Math.min(handle.size.cols, source.cols);
|
|
4327
|
+
const rows = Math.min(handle.size.rows, source.rows);
|
|
4328
|
+
for (const rect of dirtyRects) {
|
|
4329
|
+
const c1 = rect.col + rect.width;
|
|
4330
|
+
const r1 = rect.row + rect.height;
|
|
4331
|
+
if (!Number.isInteger(rect.col) || !Number.isInteger(rect.row) || !Number.isInteger(rect.width) || !Number.isInteger(rect.height) || rect.col < 0 || rect.row < 0 || rect.width < 0 || rect.height < 0 || c1 > cols || r1 > rows) throw new Error(`[SILVERY_STRICT=island-paint-oob] guest dirty rect ${formatRect(rect)} escapes island ${cols}x${rows}`);
|
|
4332
|
+
}
|
|
4333
|
+
}
|
|
4334
|
+
function assertResizeRace(handle, source) {
|
|
4335
|
+
if (!isStrictEnabled("island-resize-race", 2)) return;
|
|
4336
|
+
if (source.cols <= handle.size.cols && source.rows <= handle.size.rows) return;
|
|
4337
|
+
throw new Error(`[SILVERY_STRICT=island-resize-race] guest output ${source.cols}x${source.rows} exceeds size-owner acknowledgement ${handle.size.cols}x${handle.size.rows}`);
|
|
4338
|
+
}
|
|
4339
|
+
function assertPaintBudget(source, layout) {
|
|
4340
|
+
if (!isStrictEnabled("island-paint-budget", 2)) return;
|
|
4341
|
+
const cells = Math.max(0, Math.min(layout.width, source.cols)) * Math.max(0, Math.min(layout.height, source.rows));
|
|
4342
|
+
if (cells <= 65536) return;
|
|
4343
|
+
throw new Error(`[SILVERY_STRICT=island-paint-budget] island paints ${cells} cells in one frame (budget ${ISLAND_PAINT_BUDGET_CELLS})`);
|
|
4344
|
+
}
|
|
4345
|
+
function assertGraphemeWidth(source) {
|
|
4346
|
+
if (!isStrictEnabled("island-grapheme-width", 2)) return;
|
|
4347
|
+
for (let row = 0; row < source.rows; row++) for (let col = 0; col < source.cols; col++) {
|
|
4348
|
+
const cell = source.getCell(col, row);
|
|
4349
|
+
if (cell.continuation) {
|
|
4350
|
+
if (col === 0 || !source.getCell(col - 1, row).wide) throw new Error(`[SILVERY_STRICT=island-grapheme-width] continuation cell at ${col},${row} has no leading wide cell`);
|
|
4351
|
+
continue;
|
|
4352
|
+
}
|
|
4353
|
+
const width = graphemeWidth(cell.char);
|
|
4354
|
+
if (width === 2) {
|
|
4355
|
+
const next = col + 1 < source.cols ? source.getCell(col + 1, row) : null;
|
|
4356
|
+
if (!cell.wide || !next?.continuation) throw new Error(`[SILVERY_STRICT=island-grapheme-width] wide grapheme ${JSON.stringify(cell.char)} at ${col},${row} is missing wide+continuation metadata`);
|
|
4357
|
+
continue;
|
|
4358
|
+
}
|
|
4359
|
+
if (cell.wide || width > 2) throw new Error(`[SILVERY_STRICT=island-grapheme-width] cell ${JSON.stringify(cell.char)} at ${col},${row} declares wide=${String(cell.wide)} but measures width ${width}`);
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
function assertCursorBoundary(handle) {
|
|
4363
|
+
if (!isStrictEnabled("island-boundary-limits", 2)) return;
|
|
4364
|
+
const cursor = handle.output.cursor;
|
|
4365
|
+
if (!handle.output.cursorVisible || !cursor) return;
|
|
4366
|
+
if (cursor.row >= 0 && cursor.row < handle.size.rows && cursor.col >= 0 && cursor.col < handle.size.cols) return;
|
|
4367
|
+
throw new Error(`[SILVERY_STRICT=island-boundary-limits] guest cursor ${cursor.col},${cursor.row} escapes island ${handle.size.cols}x${handle.size.rows}`);
|
|
4368
|
+
}
|
|
4369
|
+
function assertMouseInsideIsland(handle, event) {
|
|
4370
|
+
if (!isStrictEnabled("island-boundary-limits", 2)) return;
|
|
4371
|
+
if (event.row >= 0 && event.row < handle.size.rows && event.col >= 0 && event.col < handle.size.cols) return;
|
|
4372
|
+
throw new Error(`[SILVERY_STRICT=island-boundary-limits] mouse event ${event.col},${event.row} escapes island ${handle.size.cols}x${handle.size.rows}`);
|
|
4373
|
+
}
|
|
4374
|
+
function assertNoPostDisposeCallback(state, label) {
|
|
4375
|
+
if (!isStrictEnabled("island-dispose-leak", 2)) return;
|
|
4376
|
+
if (state.lifecycle !== "disposed" && !state.abortController.signal.aborted) return;
|
|
4377
|
+
throw new Error(`[SILVERY_STRICT=island-dispose-leak] ${label} callback fired after island dispose`);
|
|
4378
|
+
}
|
|
4379
|
+
function assertNoDisposedRender(state) {
|
|
4380
|
+
if (!isStrictEnabled("island-dispose-leak", 2)) return;
|
|
4381
|
+
if (state.lifecycle !== "disposed") return;
|
|
4382
|
+
throw new Error("[SILVERY_STRICT=island-dispose-leak] disposed island reached render phase");
|
|
4383
|
+
}
|
|
4384
|
+
function formatRect(rect) {
|
|
4385
|
+
return `{col:${rect.col},row:${rect.row},width:${rect.width},height:${rect.height}}`;
|
|
4386
|
+
}
|
|
4387
|
+
//#endregion
|
|
4388
|
+
//#region packages/ag-term/src/pipeline/render-viewport.ts
|
|
4389
|
+
/**
|
|
4390
|
+
* Blit the foreign cell buffer at `node.viewportState.buffer` into `buffer`
|
|
4391
|
+
* (via `sink.emitSetCell`) at `layout` (the viewport's content rect in
|
|
4392
|
+
* absolute parent-buffer coordinates). Cells outside `buffer`'s bounds are
|
|
4393
|
+
* silently clipped — the Viewport rect's right/bottom may extend off-screen
|
|
4394
|
+
* and that's fine.
|
|
4395
|
+
*/
|
|
4396
|
+
function renderViewport(node, buffer, sink, layout, scrollOffset) {
|
|
4397
|
+
const state = node.viewportState;
|
|
4398
|
+
if (!state) return;
|
|
4399
|
+
const src = state.buffer;
|
|
4400
|
+
const baseX = layout.x;
|
|
4401
|
+
const baseY = layout.y - scrollOffset;
|
|
4402
|
+
const drawW = Math.min(layout.width, src.cols);
|
|
4403
|
+
const drawH = Math.min(layout.height, src.rows);
|
|
4404
|
+
for (let r = 0; r < drawH; r++) {
|
|
4405
|
+
const dstY = baseY + r;
|
|
4406
|
+
if (dstY < 0 || dstY >= buffer.height) continue;
|
|
4407
|
+
for (let c = 0; c < drawW; c++) {
|
|
4408
|
+
const dstX = baseX + c;
|
|
4409
|
+
if (dstX < 0 || dstX >= buffer.width) continue;
|
|
4410
|
+
const cell = src.getCell(c, r);
|
|
4411
|
+
sink.emitSetCell(dstX, dstY, viewportCellToPatch(cell));
|
|
4412
|
+
}
|
|
4413
|
+
}
|
|
4414
|
+
}
|
|
4415
|
+
/**
|
|
4416
|
+
* Convert a viewport {@link Cell} (string-colored, framework-agnostic shape
|
|
4417
|
+
* from `@silvery/ag/types`) to a {@link CellPatch} the parent
|
|
4418
|
+
* {@link TerminalBuffer} accepts (Color = `number | RGB | null`). String
|
|
4419
|
+
* colors are parsed once per cell — the upcoming xterm adapter writes
|
|
4420
|
+
* pre-resolved RGB strings, so parseColor's fast path runs.
|
|
4421
|
+
*
|
|
4422
|
+
* Reused by {@link renderIsland} — both viewport and island share the same
|
|
4423
|
+
* Cell shape from `@silvery/ag/types`. Islands pass an inherited background
|
|
4424
|
+
* so snapshot guests can leave cell.bg null and still sit on host chrome.
|
|
4425
|
+
*/
|
|
4426
|
+
function viewportCellToPatch(cell, inheritedBg = null) {
|
|
4427
|
+
return {
|
|
4428
|
+
char: cell.char,
|
|
4429
|
+
fg: cell.fg === null ? null : parseColor(cell.fg),
|
|
4430
|
+
bg: cell.bg === null ? inheritedBg : parseColor(cell.bg),
|
|
4431
|
+
attrs: cell.attrs,
|
|
4432
|
+
wide: cell.wide,
|
|
4433
|
+
continuation: cell.continuation
|
|
4434
|
+
};
|
|
4435
|
+
}
|
|
4436
|
+
/**
|
|
4437
|
+
* Blit a `silvery-island` node's guest cell buffer into the parent buffer.
|
|
4438
|
+
*
|
|
4439
|
+
* Sibling of {@link renderViewport}: reads from
|
|
4440
|
+
* `node.islandState.handle.output.buffer` (the guest's read-only output
|
|
4441
|
+
* surface from {@link IslandOutputOwner}) instead of
|
|
4442
|
+
* `node.viewportState.buffer`. Same routing — through {@link RenderSink}'s
|
|
4443
|
+
* `emitSetCell`, not direct buffer writes — so the cells survive
|
|
4444
|
+
* `commitSectionedPlan` under `SILVERY_RENDER_PLAN`.
|
|
4445
|
+
*
|
|
4446
|
+
* Bails when the host node has no `islandState` (factory still mounting) or
|
|
4447
|
+
* the guest's `init()` hasn't resolved yet (`handle === null`, lifecycle
|
|
4448
|
+
* `"pending"` / `"errored"` / `"disposed"`). In that case the parent's
|
|
4449
|
+
* `clearNodeRegion` (or inherited bg fill) has already painted blanks at
|
|
4450
|
+
* the island's rect, so we paint nothing and the host chrome shows through.
|
|
4451
|
+
*
|
|
4452
|
+
* Cursor handling: `IslandOutputOwner.cursor` is the guest's internal
|
|
4453
|
+
* cursor descriptor. v1 (Phase 1) does NOT render the guest cursor into the
|
|
4454
|
+
* host frame — the host cursor sits OUTSIDE the island, and the
|
|
4455
|
+
* `IslandModesOwner` contract un-applies the host cursor on focus blur to
|
|
4456
|
+
* the island. Phase 3 of `@km/silvery/15646-islands` wires the guest cursor
|
|
4457
|
+
* into the host's cursor signal (separate epic unit); until then, the
|
|
4458
|
+
* cursor field is read by the focus aggregator, not the blit.
|
|
4459
|
+
*
|
|
4460
|
+
* Clipping: same as viewport — out-of-bounds cells are silently dropped.
|
|
4461
|
+
* Both axes (right + bottom) clip; an island whose `cols×rows` overshoots
|
|
4462
|
+
* the parent buffer paints only its in-bounds intersection.
|
|
4463
|
+
*
|
|
4464
|
+
* See {@link island-types.ts} in `@silvery/ag` and bead
|
|
4465
|
+
* `@km/silvery/15646-islands`.
|
|
4466
|
+
*/
|
|
4467
|
+
function renderIsland(node, buffer, sink, layout, scrollOffset, inheritedBg = null) {
|
|
4468
|
+
const state = node.islandState;
|
|
4469
|
+
if (!state) return;
|
|
4470
|
+
const handle = state.handle;
|
|
4471
|
+
if (!handle) return;
|
|
4472
|
+
ensureIslandStrictInstrumentation(node);
|
|
4473
|
+
assertIslandRenderInvariants(node, layout);
|
|
4474
|
+
const src = handle.output.buffer;
|
|
4475
|
+
const baseX = layout.x;
|
|
4476
|
+
const baseY = layout.y - scrollOffset;
|
|
4477
|
+
const drawW = Math.min(layout.width, src.cols);
|
|
4478
|
+
const drawH = Math.min(layout.height, src.rows);
|
|
4479
|
+
for (let r = 0; r < drawH; r++) {
|
|
4480
|
+
const dstY = baseY + r;
|
|
4481
|
+
if (dstY < 0 || dstY >= buffer.height) continue;
|
|
4482
|
+
for (let c = 0; c < drawW; c++) {
|
|
4483
|
+
const dstX = baseX + c;
|
|
4484
|
+
if (dstX < 0 || dstX >= buffer.width) continue;
|
|
4485
|
+
const cell = src.getCell(c, r);
|
|
4486
|
+
sink.emitSetCell(dstX, dstY, viewportCellToPatch(cell, inheritedBg));
|
|
4487
|
+
}
|
|
4488
|
+
}
|
|
2653
4489
|
}
|
|
2654
4490
|
//#endregion
|
|
2655
4491
|
//#region packages/ag-term/src/pipeline/decoration-phase.ts
|
|
@@ -2658,12 +4494,20 @@ function padCenter(text, width) {
|
|
|
2658
4494
|
* state. Called at the start of each incremental render, before the content
|
|
2659
4495
|
* phase, on the cloned buffer. No-op when there are no previous snapshots
|
|
2660
4496
|
* (fresh render or no outlines last frame).
|
|
4497
|
+
*
|
|
4498
|
+
* Phase 2 Step 5 (paint-clear-invariant L5): snapshots are read from the
|
|
4499
|
+
* `RenderPostState` carrier owned by `createAg`, NOT from the buffer. The
|
|
4500
|
+
* sink still receives a `setOutlineSnapshots([])` op so the plan-shape
|
|
4501
|
+
* captures the cleanup intent for parity tests; the authoritative state
|
|
4502
|
+
* lives in `postState.outlineSnapshots`.
|
|
2661
4503
|
*/
|
|
2662
|
-
function clearPreviousOutlines(buffer) {
|
|
2663
|
-
const snapshots =
|
|
2664
|
-
if (
|
|
2665
|
-
|
|
2666
|
-
|
|
4504
|
+
function clearPreviousOutlines(buffer, postState) {
|
|
4505
|
+
const snapshots = postState.outlineSnapshots;
|
|
4506
|
+
if (snapshots.length === 0) return;
|
|
4507
|
+
const sink = createFrameSink(buffer);
|
|
4508
|
+
for (const snap of snapshots) sink.emitSetCell(snap.x, snap.y, snap.cell);
|
|
4509
|
+
postState.outlineSnapshots = [];
|
|
4510
|
+
sink.setOutlineSnapshots([]);
|
|
2667
4511
|
}
|
|
2668
4512
|
/**
|
|
2669
4513
|
* Walk the node tree, drawing outlines for every node with `outlineStyle`.
|
|
@@ -2673,18 +4517,25 @@ function clearPreviousOutlines(buffer) {
|
|
|
2673
4517
|
* incremental). Mirrors `renderNodeToBuffer`'s state threading for scroll
|
|
2674
4518
|
* offsets, clip bounds, and inherited background — but does nothing except
|
|
2675
4519
|
* visit the tree and draw outlines.
|
|
4520
|
+
*
|
|
4521
|
+
* Phase 2 Step 5 (paint-clear-invariant L5): snapshots are written to the
|
|
4522
|
+
* `RenderPostState` carrier; the sink op is recorded in the plan for parity
|
|
4523
|
+
* tests only. After this call, `postState.outlineSnapshots` holds exactly
|
|
4524
|
+
* the cells that need restoring on the next frame.
|
|
2676
4525
|
*/
|
|
2677
|
-
function renderDecorationPass(buffer, root) {
|
|
4526
|
+
function renderDecorationPass(buffer, root, postState) {
|
|
2678
4527
|
const snapshots = [];
|
|
2679
|
-
|
|
2680
|
-
buffer
|
|
4528
|
+
const sink = createFrameSink(buffer);
|
|
4529
|
+
walk(root, buffer, sink, 0, void 0, { color: null }, snapshots);
|
|
4530
|
+
postState.outlineSnapshots = snapshots;
|
|
4531
|
+
sink.setOutlineSnapshots(snapshots);
|
|
2681
4532
|
}
|
|
2682
4533
|
/**
|
|
2683
4534
|
* Recursive tree walk. Each invocation corresponds to the state at a single
|
|
2684
4535
|
* node — scroll offset, clip bounds, inherited background — matching what
|
|
2685
4536
|
* `renderNodeToBuffer` would have threaded through its `NodeRenderState`.
|
|
2686
4537
|
*/
|
|
2687
|
-
function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
|
|
4538
|
+
function walk(node, buffer, sink, scrollOffset, clipBounds, inheritedBg, snapshots) {
|
|
2688
4539
|
if (!node.layoutNode) return;
|
|
2689
4540
|
const layout = node.boxRect;
|
|
2690
4541
|
if (!layout) return;
|
|
@@ -2692,20 +4543,23 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
|
|
|
2692
4543
|
const props = node.props;
|
|
2693
4544
|
if (props.display === "none") return;
|
|
2694
4545
|
const y = layout.y - scrollOffset;
|
|
2695
|
-
if (y >=
|
|
4546
|
+
if (y >= sink.height || y + layout.height <= 0) return;
|
|
2696
4547
|
const effectiveBg = getEffectiveBg(props);
|
|
2697
4548
|
const theme = props.theme;
|
|
2698
4549
|
const themeBg = theme && typeof theme["bg-surface-default"] === "string" ? theme["bg-surface-default"] : theme && typeof theme["bg"] === "string" ? theme["bg"] : void 0;
|
|
2699
4550
|
const childInheritedBg = effectiveBg ? { color: parseColor(effectiveBg) } : themeBg !== void 0 ? { color: parseColor(themeBg) } : inheritedBg;
|
|
2700
4551
|
if (node.type === "silvery-box" && props.outlineStyle) {
|
|
2701
4552
|
const boxInheritedBg = effectiveBg ? void 0 : inheritedBg.color;
|
|
2702
|
-
const positions = collectOutlineCells(layout.x, y, layout.width, layout.height, props, clipBounds,
|
|
4553
|
+
const positions = collectOutlineCells(layout.x, y, layout.width, layout.height, props, clipBounds, sink.width, sink.height);
|
|
2703
4554
|
for (const pos of positions) snapshots.push({
|
|
2704
4555
|
x: pos.x,
|
|
2705
4556
|
y: pos.y,
|
|
2706
|
-
cell:
|
|
4557
|
+
cell: {
|
|
4558
|
+
...buffer.getCell(pos.x, pos.y),
|
|
4559
|
+
selectable: buffer.isCellSelectable(pos.x, pos.y)
|
|
4560
|
+
}
|
|
2707
4561
|
});
|
|
2708
|
-
renderOutline(buffer, layout.x, y, layout.width, layout.height, props, clipBounds, boxInheritedBg);
|
|
4562
|
+
renderOutline(buffer, sink, layout.x, y, layout.width, layout.height, props, clipBounds, boxInheritedBg);
|
|
2709
4563
|
}
|
|
2710
4564
|
if (node.children.length === 0) return;
|
|
2711
4565
|
const isScrollContainer = props.overflow === "scroll" && node.scrollState;
|
|
@@ -2719,16 +4573,16 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
|
|
|
2719
4573
|
if (!child) continue;
|
|
2720
4574
|
if (child.props.position === "sticky") continue;
|
|
2721
4575
|
if (i < ss.firstVisibleChild || i > ss.lastVisibleChild) continue;
|
|
2722
|
-
walk(child, buffer, ss.offset, childClip, childInheritedBg, snapshots);
|
|
4576
|
+
walk(child, buffer, sink, ss.offset, childClip, childInheritedBg, snapshots);
|
|
2723
4577
|
}
|
|
2724
4578
|
if (ss.stickyChildren) for (const sticky of ss.stickyChildren) {
|
|
2725
4579
|
const child = node.children[sticky.index];
|
|
2726
4580
|
if (!child) continue;
|
|
2727
|
-
walk(child, buffer, sticky.naturalTop - sticky.renderOffset, childClip, childInheritedBg, snapshots);
|
|
4581
|
+
walk(child, buffer, sink, sticky.naturalTop - sticky.renderOffset, childClip, childInheritedBg, snapshots);
|
|
2728
4582
|
}
|
|
2729
4583
|
} else {
|
|
2730
4584
|
const childClip = clipX || clipY ? computeChildClip(layout, props, clipBounds, scrollOffset, clipX, clipY) : clipBounds;
|
|
2731
|
-
for (const child of node.children) walk(child, buffer, scrollOffset, childClip, childInheritedBg, snapshots);
|
|
4585
|
+
for (const child of node.children) walk(child, buffer, sink, scrollOffset, childClip, childInheritedBg, snapshots);
|
|
2732
4586
|
}
|
|
2733
4587
|
}
|
|
2734
4588
|
/**
|
|
@@ -2741,19 +4595,19 @@ function walk(node, buffer, scrollOffset, clipBounds, inheritedBg, snapshots) {
|
|
|
2741
4595
|
* Uses the SAME visibility checks as `renderOutline` so the snapshot set is
|
|
2742
4596
|
* an exact match for the cell set the renderer will overwrite.
|
|
2743
4597
|
*/
|
|
2744
|
-
function collectOutlineCells(x, y, width, height, props, clipBounds,
|
|
4598
|
+
function collectOutlineCells(x, y, width, height, props, clipBounds, bufferWidth, bufferHeight) {
|
|
2745
4599
|
const out = [];
|
|
2746
4600
|
const ox = x - 1;
|
|
2747
4601
|
const oy = y - 1;
|
|
2748
4602
|
const ow = width + 2;
|
|
2749
4603
|
const oh = height + 2;
|
|
2750
4604
|
const isRowVisible = (row) => {
|
|
2751
|
-
if (!clipBounds) return row >= 0 && row <
|
|
2752
|
-
return row >= clipBounds.top && row < clipBounds.bottom && row <
|
|
4605
|
+
if (!clipBounds) return row >= 0 && row < bufferHeight;
|
|
4606
|
+
return row >= clipBounds.top && row < clipBounds.bottom && row < bufferHeight;
|
|
2753
4607
|
};
|
|
2754
4608
|
const isColVisible = (col) => {
|
|
2755
|
-
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col <
|
|
2756
|
-
return col >= clipBounds.left && col < clipBounds.right && col <
|
|
4609
|
+
if (clipBounds?.left === void 0 || clipBounds.right === void 0) return col >= 0 && col < bufferWidth;
|
|
4610
|
+
return col >= clipBounds.left && col < clipBounds.right && col < bufferWidth;
|
|
2757
4611
|
};
|
|
2758
4612
|
const showTop = props.outlineTop !== false;
|
|
2759
4613
|
const showBottom = props.outlineBottom !== false;
|
|
@@ -2764,11 +4618,11 @@ function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
|
|
|
2764
4618
|
x: ox,
|
|
2765
4619
|
y: oy
|
|
2766
4620
|
});
|
|
2767
|
-
for (let col = ox + 1; col < ox + ow - 1 && col <
|
|
4621
|
+
for (let col = ox + 1; col < ox + ow - 1 && col < bufferWidth; col++) if (isColVisible(col)) out.push({
|
|
2768
4622
|
x: col,
|
|
2769
4623
|
y: oy
|
|
2770
4624
|
});
|
|
2771
|
-
if (showRight && ox + ow - 1 <
|
|
4625
|
+
if (showRight && ox + ow - 1 < bufferWidth && isColVisible(ox + ow - 1)) out.push({
|
|
2772
4626
|
x: ox + ow - 1,
|
|
2773
4627
|
y: oy
|
|
2774
4628
|
});
|
|
@@ -2781,7 +4635,7 @@ function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
|
|
|
2781
4635
|
x: ox,
|
|
2782
4636
|
y: row
|
|
2783
4637
|
});
|
|
2784
|
-
if (showRight && ox + ow - 1 <
|
|
4638
|
+
if (showRight && ox + ow - 1 < bufferWidth && isColVisible(ox + ow - 1)) out.push({
|
|
2785
4639
|
x: ox + ow - 1,
|
|
2786
4640
|
y: row
|
|
2787
4641
|
});
|
|
@@ -2792,11 +4646,11 @@ function collectOutlineCells(x, y, width, height, props, clipBounds, buffer) {
|
|
|
2792
4646
|
x: ox,
|
|
2793
4647
|
y: bottomY
|
|
2794
4648
|
});
|
|
2795
|
-
for (let col = ox + 1; col < ox + ow - 1 && col <
|
|
4649
|
+
for (let col = ox + 1; col < ox + ow - 1 && col < bufferWidth; col++) if (isColVisible(col)) out.push({
|
|
2796
4650
|
x: col,
|
|
2797
4651
|
y: bottomY
|
|
2798
4652
|
});
|
|
2799
|
-
if (showRight && ox + ow - 1 <
|
|
4653
|
+
if (showRight && ox + ow - 1 < bufferWidth && isColVisible(ox + ow - 1)) out.push({
|
|
2800
4654
|
x: ox + ow - 1,
|
|
2801
4655
|
y: bottomY
|
|
2802
4656
|
});
|
|
@@ -2843,6 +4697,15 @@ function computeChildClip(layout, props, parentClip, scrollOffset, horizontal, v
|
|
|
2843
4697
|
return result;
|
|
2844
4698
|
}
|
|
2845
4699
|
//#endregion
|
|
4700
|
+
//#region packages/ag-term/src/pipeline/render-post-state.ts
|
|
4701
|
+
/**
|
|
4702
|
+
* Construct an empty `RenderPostState`. Called once per `createAg` lifetime
|
|
4703
|
+
* (and on `resetBuffer` to mirror prevBuffer reset semantics).
|
|
4704
|
+
*/
|
|
4705
|
+
function createRenderPostState() {
|
|
4706
|
+
return { outlineSnapshots: [] };
|
|
4707
|
+
}
|
|
4708
|
+
//#endregion
|
|
2846
4709
|
//#region packages/ag-term/src/pipeline/cascade-predicates.ts
|
|
2847
4710
|
/**
|
|
2848
4711
|
* Compute all cascade predicate values from boolean inputs.
|
|
@@ -3049,9 +4912,17 @@ const cellLog = createLogger("silvery:content:cell");
|
|
|
3049
4912
|
*
|
|
3050
4913
|
* @param root The root SilveryNode
|
|
3051
4914
|
* @param prevBuffer Previous buffer for incremental rendering (optional)
|
|
4915
|
+
* @param ctx Pipeline context (instrumentation, measurer)
|
|
4916
|
+
* @param postState Cross-frame post-state carrier (outline snapshots, etc.).
|
|
4917
|
+
* Owned by `createAg`; passed in here rather than read from the buffer so
|
|
4918
|
+
* the decoration phase can run against either a BufferSink-mutated buffer
|
|
4919
|
+
* or a PlanSink-committed buffer (Phase 2 Step 5 of paint-clear-invariant L5).
|
|
4920
|
+
* When omitted (legacy callers), a fresh empty post-state is used per call —
|
|
4921
|
+
* correct for fresh renders, but means outline cleanup is lost across frames
|
|
4922
|
+
* if the caller doesn't hold the post-state externally.
|
|
3052
4923
|
* @returns A TerminalBuffer with the rendered content
|
|
3053
4924
|
*/
|
|
3054
|
-
function renderPhase(root, prevBuffer, ctx) {
|
|
4925
|
+
function renderPhase(root, prevBuffer, ctx, postState = createRenderPostState()) {
|
|
3055
4926
|
const layout = root.boxRect;
|
|
3056
4927
|
if (!layout) throw new Error("renderPhase called before layout phase");
|
|
3057
4928
|
const instr = resolveInstrumentation(ctx);
|
|
@@ -3073,10 +4944,10 @@ function renderPhase(root, prevBuffer, ctx) {
|
|
|
3073
4944
|
const t0 = instr.enabled ? performance.now() : 0;
|
|
3074
4945
|
const buffer = hasPrevBuffer ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height);
|
|
3075
4946
|
const tClone = instr.enabled ? performance.now() - t0 : 0;
|
|
3076
|
-
buffer
|
|
3077
|
-
clearPreviousOutlines(buffer);
|
|
4947
|
+
const frameSink = createFrameSink(buffer);
|
|
4948
|
+
clearPreviousOutlines(buffer, postState);
|
|
3078
4949
|
const t1 = instr.enabled ? performance.now() : 0;
|
|
3079
|
-
renderNodeToBuffer(root, buffer, {
|
|
4950
|
+
renderNodeToBuffer(root, buffer, frameSink, {
|
|
3080
4951
|
scrollOffset: 0,
|
|
3081
4952
|
clipBounds: void 0,
|
|
3082
4953
|
hasPrevBuffer: !!hasPrevBuffer,
|
|
@@ -3087,10 +4958,11 @@ function renderPhase(root, prevBuffer, ctx) {
|
|
|
3087
4958
|
color: null,
|
|
3088
4959
|
ancestorRect: null
|
|
3089
4960
|
},
|
|
3090
|
-
inheritedFg: null
|
|
4961
|
+
inheritedFg: null,
|
|
4962
|
+
selectableMode: true
|
|
3091
4963
|
}, ctx);
|
|
3092
4964
|
const tRender = instr.enabled ? performance.now() - t1 : 0;
|
|
3093
|
-
renderDecorationPass(buffer, root);
|
|
4965
|
+
renderDecorationPass(buffer, root, postState);
|
|
3094
4966
|
if (instr.enabled) emitRenderPhaseStats(instr.stats, instr.nodeTrace, instr.nodeTraceEnabled, tClone, tRender);
|
|
3095
4967
|
syncPrevLayout(root, isCurrentEpoch(root.layoutChangedThisFrame) || isDirty(root.dirtyBits, root.dirtyEpoch, 16) || !hasPrevBuffer);
|
|
3096
4968
|
advanceRenderEpoch();
|
|
@@ -3156,6 +5028,7 @@ const _renderPhaseStats = {
|
|
|
3156
5028
|
scrollClearReason: "",
|
|
3157
5029
|
normalChildrenRepaint: 0,
|
|
3158
5030
|
normalRepaintReason: "",
|
|
5031
|
+
siblingOverlapForced: 0,
|
|
3159
5032
|
cascadeMinDepth: 999,
|
|
3160
5033
|
cascadeNodes: "",
|
|
3161
5034
|
_noopSkip: 0,
|
|
@@ -3234,8 +5107,14 @@ function emitRenderPhaseStats(stats, nodeTrace, nodeTraceEnabled, tClone, tRende
|
|
|
3234
5107
|
}
|
|
3235
5108
|
/**
|
|
3236
5109
|
* Render a single node to the buffer.
|
|
5110
|
+
*
|
|
5111
|
+
* `sink` is the frame-shared RenderSink threaded from `renderPhase`. All
|
|
5112
|
+
* post-state writes (selectableMode), cleanup ops (executeRegionClearing),
|
|
5113
|
+
* paint ops (renderOwnContent inner sinks), and overlay ops
|
|
5114
|
+
* (applyBoxAttrOverlay) route through this single sink. See Smell #2 from
|
|
5115
|
+
* the 2026-04-27 dual-pro review for the rationale.
|
|
3237
5116
|
*/
|
|
3238
|
-
function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
5117
|
+
function renderNodeToBuffer(node, buffer, sink, nodeState, ctx) {
|
|
3239
5118
|
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged = false } = nodeState;
|
|
3240
5119
|
const instr = resolveInstrumentation(ctx);
|
|
3241
5120
|
if (instr.enabled) instr.stats.nodesVisited++;
|
|
@@ -3250,20 +5129,12 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
|
3250
5129
|
return;
|
|
3251
5130
|
}
|
|
3252
5131
|
const props = node.props;
|
|
3253
|
-
const prevSelectableMode = buffer.getSelectableMode();
|
|
3254
|
-
const userSelect = props.userSelect;
|
|
3255
|
-
if (userSelect === "none") buffer.setSelectableMode(false);
|
|
3256
|
-
else if (userSelect === "text" || userSelect === "contain") buffer.setSelectableMode(true);
|
|
3257
5132
|
if (props.display === "none") {
|
|
3258
5133
|
clearDirtyFlags(node);
|
|
3259
|
-
buffer.setSelectableMode(prevSelectableMode);
|
|
3260
5134
|
return;
|
|
3261
5135
|
}
|
|
3262
5136
|
const screenY = layout.y - scrollOffset;
|
|
3263
|
-
if (screenY >= buffer.height || screenY + layout.height <= 0)
|
|
3264
|
-
buffer.setSelectableMode(prevSelectableMode);
|
|
3265
|
-
return;
|
|
3266
|
-
}
|
|
5137
|
+
if (screenY >= buffer.height || screenY + layout.height <= 0) return;
|
|
3267
5138
|
const layoutChanged = isCurrentEpoch(node.layoutChangedThisFrame);
|
|
3268
5139
|
const childPositionChanged = !!(hasPrevBuffer && !layoutChanged && hasChildPositionChanged(node));
|
|
3269
5140
|
const scrollOffsetChanged = !!(node.scrollState && node.scrollState.offset !== node.scrollState.prevOffset);
|
|
@@ -3298,7 +5169,6 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
|
3298
5169
|
});
|
|
3299
5170
|
}
|
|
3300
5171
|
clearDirtyFlags(node);
|
|
3301
|
-
buffer.setSelectableMode(prevSelectableMode);
|
|
3302
5172
|
return;
|
|
3303
5173
|
}
|
|
3304
5174
|
if (instr.enabled) {
|
|
@@ -3312,6 +5182,15 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
|
3312
5182
|
if (childPositionChanged) instr.stats.flagChildPositionChanged++;
|
|
3313
5183
|
if (ancestorLayoutChanged) instr.stats.flagAncestorLayoutChanged++;
|
|
3314
5184
|
}
|
|
5185
|
+
const prevSelectableMode = nodeState.selectableMode;
|
|
5186
|
+
const userSelect = props.userSelect;
|
|
5187
|
+
let currentSelectableMode = prevSelectableMode;
|
|
5188
|
+
if (userSelect === "none") currentSelectableMode = false;
|
|
5189
|
+
else if (userSelect === "text" || userSelect === "contain") currentSelectableMode = true;
|
|
5190
|
+
const currentNodeState = currentSelectableMode === prevSelectableMode ? nodeState : {
|
|
5191
|
+
...nodeState,
|
|
5192
|
+
selectableMode: currentSelectableMode
|
|
5193
|
+
};
|
|
3315
5194
|
const nodeTheme = props.theme;
|
|
3316
5195
|
if (nodeTheme) pushContextTheme(nodeTheme);
|
|
3317
5196
|
try {
|
|
@@ -3357,13 +5236,31 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
|
3357
5236
|
childrenNeedFreshRender
|
|
3358
5237
|
};
|
|
3359
5238
|
}
|
|
5239
|
+
if (node.type === "silvery-box" && isDirty(node.dirtyBits, node.dirtyEpoch, 2) && (mayHaveBoxAttrOverlay(props) || node.hadBoxAttrOverlay)) {
|
|
5240
|
+
const contentRegionCleared = (hasPrevBuffer || ancestorCleared) && !getEffectiveBg(props);
|
|
5241
|
+
const childrenNeedFreshRender = hasPrevBuffer || ancestorCleared;
|
|
5242
|
+
cascade = {
|
|
5243
|
+
...cascade,
|
|
5244
|
+
contentAreaAffected: true,
|
|
5245
|
+
bgOnlyChange: false,
|
|
5246
|
+
contentRegionCleared,
|
|
5247
|
+
childrenNeedFreshRender
|
|
5248
|
+
};
|
|
5249
|
+
}
|
|
3360
5250
|
const { contentRegionCleared, skipBgFill, childrenNeedFreshRender } = cascade;
|
|
3361
5251
|
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);
|
|
3362
5252
|
const useTextStyleFastPath = false;
|
|
3363
|
-
executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instr.enabled, instr.stats, nodeState.inheritedBg);
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
5253
|
+
executeRegionClearing(node, buffer, sink, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instr.enabled, instr.stats, nodeState.inheritedBg, hasPrevBuffer);
|
|
5254
|
+
if (!hasPrevBuffer || ancestorCleared || ancestorLayoutChanged || cascade.contentAreaAffected || isDirty(node.dirtyBits, node.dirtyEpoch, 2) || cascade.bgRefillNeeded) {
|
|
5255
|
+
pushRenderingNode(node);
|
|
5256
|
+
resetBorderedRectClipCache();
|
|
5257
|
+
try {
|
|
5258
|
+
renderOwnContent(node, buffer, sink, layout, props, currentNodeState, skipBgFill, instr.enabled, instr.stats, ctx, cascade.bgOnlyChange, useTextStyleFastPath);
|
|
5259
|
+
} finally {
|
|
5260
|
+
popRenderingNode();
|
|
5261
|
+
resetBorderedRectClipCache();
|
|
5262
|
+
}
|
|
5263
|
+
}
|
|
3367
5264
|
const effectiveBg = getEffectiveBg(props);
|
|
3368
5265
|
const childInheritedBg = effectiveBg ? {
|
|
3369
5266
|
color: parseColor(effectiveBg),
|
|
@@ -3374,18 +5271,19 @@ function renderNodeToBuffer(node, buffer, nodeState, ctx) {
|
|
|
3374
5271
|
} : nodeState.inheritedBg;
|
|
3375
5272
|
const childInheritedFg = props.color === "inherit" || props.color === "currentColor" ? nodeState.inheritedFg : props.color ? parseColor(props.color) : nodeTheme ? parseColor(nodeTheme.fg) : nodeState.inheritedFg;
|
|
3376
5273
|
const childState = {
|
|
3377
|
-
...
|
|
5274
|
+
...currentNodeState,
|
|
3378
5275
|
inheritedBg: childInheritedBg,
|
|
3379
|
-
inheritedFg: childInheritedFg
|
|
5276
|
+
inheritedFg: childInheritedFg,
|
|
5277
|
+
selectableMode: currentSelectableMode
|
|
3380
5278
|
};
|
|
3381
5279
|
if (isScrollContainer) {
|
|
3382
|
-
renderScrollContainerChildren(node, buffer, props, childState, contentRegionCleared, childrenNeedFreshRender, ctx);
|
|
5280
|
+
renderScrollContainerChildren(node, buffer, sink, props, childState, contentRegionCleared, childrenNeedFreshRender, ctx);
|
|
3383
5281
|
renderScrollIndicators(node, buffer, layout, props, node.scrollState, ctx);
|
|
3384
|
-
} else renderNormalChildren(node, buffer, props, childState, childPositionChanged, contentRegionCleared, childrenNeedFreshRender, ctx);
|
|
5282
|
+
} else renderNormalChildren(node, buffer, sink, props, childState, childPositionChanged, contentRegionCleared, childrenNeedFreshRender, ctx);
|
|
5283
|
+
if (node.type === "silvery-box") node.hadBoxAttrOverlay = applyBoxAttrOverlay(buffer, sink, layout, props, scrollOffset, clipBounds);
|
|
3385
5284
|
clearNodeDirtyFlags(node);
|
|
3386
5285
|
} finally {
|
|
3387
5286
|
if (nodeTheme) popContextTheme();
|
|
3388
|
-
buffer.setSelectableMode(prevSelectableMode);
|
|
3389
5287
|
}
|
|
3390
5288
|
}
|
|
3391
5289
|
/**
|
|
@@ -3486,12 +5384,14 @@ function traceRenderDecision(node, props, layout, screenY, scrollOffset, hasPrev
|
|
|
3486
5384
|
*
|
|
3487
5385
|
* All clearing runs BEFORE renderBox/renderText so borders drawn later are not overwritten.
|
|
3488
5386
|
*/
|
|
3489
|
-
function executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instrumentEnabled, stats, threadedInheritedBg) {
|
|
5387
|
+
function executeRegionClearing(node, buffer, sink, layout, scrollOffset, clipBounds, bufferIsCloned, layoutChanged, contentRegionCleared, descendantOverflowChanged, instrumentEnabled, stats, threadedInheritedBg, hasPrevBuffer) {
|
|
3490
5388
|
if (contentRegionCleared) {
|
|
3491
5389
|
if (instrumentEnabled) stats.clearOps++;
|
|
3492
|
-
clearNodeRegion(node,
|
|
3493
|
-
}
|
|
3494
|
-
|
|
5390
|
+
clearNodeRegion(node, sink, layout, scrollOffset, clipBounds, threadedInheritedBg);
|
|
5391
|
+
}
|
|
5392
|
+
const excessGate = requireExcessClearGate(bufferIsCloned, layoutChanged, node.prevLayout, hasPrevBuffer);
|
|
5393
|
+
if (excessGate) clearExcessArea(excessGate, node, buffer, sink, layout, scrollOffset, clipBounds, threadedInheritedBg);
|
|
5394
|
+
if (descendantOverflowChanged) clearDescendantOverflowRegions(node, buffer, sink, layout, scrollOffset, clipBounds, threadedInheritedBg);
|
|
3495
5395
|
}
|
|
3496
5396
|
/**
|
|
3497
5397
|
* Render this node's own content (box background/border or text).
|
|
@@ -3501,12 +5401,14 @@ function executeRegionClearing(node, buffer, layout, scrollOffset, clipBounds, b
|
|
|
3501
5401
|
*
|
|
3502
5402
|
* @returns The boxInheritedBg color (needed by outline rendering after children).
|
|
3503
5403
|
*/
|
|
3504
|
-
function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, instrumentEnabled, stats, ctx, bgOnlyChange = false, useTextStyleFastPath = false) {
|
|
5404
|
+
function renderOwnContent(node, buffer, sink, layout, props, nodeState, skipBgFill, instrumentEnabled, stats, ctx, bgOnlyChange = false, useTextStyleFastPath = false) {
|
|
3505
5405
|
const boxInheritedBg = node.type === "silvery-box" && !getEffectiveBg(props) ? nodeState.inheritedBg.color : void 0;
|
|
3506
5406
|
if (node.type === "silvery-box") {
|
|
3507
5407
|
if (instrumentEnabled) stats.boxNodes++;
|
|
3508
5408
|
renderBox(node, buffer, layout, props, nodeState, skipBgFill, boxInheritedBg, bgOnlyChange, nodeState.inheritedFg);
|
|
3509
|
-
} else if (node.type === "silvery-
|
|
5409
|
+
} else if (node.type === "silvery-viewport") renderViewport(node, buffer, sink, layout, nodeState.scrollOffset);
|
|
5410
|
+
else if (node.type === "silvery-island") renderIsland(node, buffer, sink, layout, nodeState.scrollOffset, nodeState.inheritedBg.color);
|
|
5411
|
+
else if (node.type === "silvery-text") {
|
|
3510
5412
|
if (instrumentEnabled) stats.textNodes++;
|
|
3511
5413
|
const textInheritedBg = nodeState.inheritedBg.color;
|
|
3512
5414
|
const textInheritedFg = nodeState.inheritedFg;
|
|
@@ -3516,7 +5418,7 @@ function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, in
|
|
|
3516
5418
|
const effectiveBg = style.bg !== null ? style.bg : textInheritedBg ?? null;
|
|
3517
5419
|
const { x, width, height } = layout;
|
|
3518
5420
|
const y = layout.y - nodeState.scrollOffset;
|
|
3519
|
-
|
|
5421
|
+
sink.emitRestyleRegion(x, y, width, height, {
|
|
3520
5422
|
fg: style.fg,
|
|
3521
5423
|
bg: effectiveBg,
|
|
3522
5424
|
underlineColor: style.underlineColor ?? null,
|
|
@@ -3527,6 +5429,125 @@ function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, in
|
|
|
3527
5429
|
return boxInheritedBg;
|
|
3528
5430
|
}
|
|
3529
5431
|
/**
|
|
5432
|
+
* Cheap check: is ANY attr-overlay prop present on this Box right now?
|
|
5433
|
+
*
|
|
5434
|
+
* Used by the cascade to decide whether `stylePropsDirty` on a silvery-box
|
|
5435
|
+
* should escalate to `contentAreaAffected`. We can't reliably inspect the
|
|
5436
|
+
* PREVIOUS frame's props from here, so we conservatively escalate whenever
|
|
5437
|
+
* EITHER the current frame OR a neighbor frame might have had overlay.
|
|
5438
|
+
* The single-call common case (Box with no attr overlay ever) stays on the
|
|
5439
|
+
* fast path because this returns false both frames and the stylePropsDirty
|
|
5440
|
+
* gate doesn't fire (stylePropsDirty requires a prop-change reconciler event,
|
|
5441
|
+
* which won't happen for a Box that never had these props).
|
|
5442
|
+
*/
|
|
5443
|
+
function mayHaveBoxAttrOverlay(props) {
|
|
5444
|
+
return !!(props.underline || props.underlineStyle || props.underlineColor || props.overline || props.bold || props.italic || props.inverse || props.strikethrough);
|
|
5445
|
+
}
|
|
5446
|
+
/**
|
|
5447
|
+
* Detect which attr props are active on a Box. Only returns the overlay
|
|
5448
|
+
* subset — attrs that benefit from merge-over-children semantics.
|
|
5449
|
+
*
|
|
5450
|
+
* bold / dim / italic / inverse / strikethrough / underline / underlineColor
|
|
5451
|
+
* all qualify. Text color, bg color, and border-related props are handled
|
|
5452
|
+
* elsewhere and are NOT attr-overlay props.
|
|
5453
|
+
*
|
|
5454
|
+
* Returns null when no overlay attrs are present (fast path — most Boxes).
|
|
5455
|
+
*/
|
|
5456
|
+
function computeBoxAttrOverlay(props) {
|
|
5457
|
+
let underlineStyle;
|
|
5458
|
+
if (props.underlineStyle !== void 0 && props.underlineStyle !== false) underlineStyle = props.underlineStyle;
|
|
5459
|
+
else if (typeof props.underline === "string") underlineStyle = props.underline;
|
|
5460
|
+
else if (props.underline === true) underlineStyle = "single";
|
|
5461
|
+
const hasUnderline = !!underlineStyle;
|
|
5462
|
+
const bold = !!props.bold;
|
|
5463
|
+
const italic = !!props.italic;
|
|
5464
|
+
const inverse = !!props.inverse;
|
|
5465
|
+
const strikethrough = !!props.strikethrough;
|
|
5466
|
+
const overline = !!props.overline;
|
|
5467
|
+
if (!hasUnderline && !bold && !italic && !inverse && !strikethrough && !overline) return null;
|
|
5468
|
+
return {
|
|
5469
|
+
attrs: {
|
|
5470
|
+
bold: bold || void 0,
|
|
5471
|
+
italic: italic || void 0,
|
|
5472
|
+
inverse: inverse || void 0,
|
|
5473
|
+
strikethrough: strikethrough || void 0,
|
|
5474
|
+
overline: overline || void 0,
|
|
5475
|
+
underline: hasUnderline || void 0,
|
|
5476
|
+
underlineStyle: hasUnderline ? underlineStyle : void 0
|
|
5477
|
+
},
|
|
5478
|
+
underlineColor: (props.underlineColor ? parseColor(props.underlineColor) : void 0) ?? void 0
|
|
5479
|
+
};
|
|
5480
|
+
}
|
|
5481
|
+
/**
|
|
5482
|
+
* Apply a Box's attr overlay to its rect, respecting scroll offset and clip
|
|
5483
|
+
* bounds. Runs AFTER children render so attrs layer on top of content without
|
|
5484
|
+
* overwriting glyphs/fg/bg.
|
|
5485
|
+
*
|
|
5486
|
+
* @returns true when an overlay was actually applied — used by the caller to
|
|
5487
|
+
* set `node.hadBoxAttrOverlay`, which next frame's cascade reads to detect
|
|
5488
|
+
* overlay removal.
|
|
5489
|
+
*/
|
|
5490
|
+
function applyBoxAttrOverlay(buffer, sink, layout, props, scrollOffset, clipBounds) {
|
|
5491
|
+
const overlay = computeBoxAttrOverlay(props);
|
|
5492
|
+
if (!overlay) return false;
|
|
5493
|
+
const x = layout.x;
|
|
5494
|
+
const y = layout.y - scrollOffset;
|
|
5495
|
+
let rx = x;
|
|
5496
|
+
let rw = layout.width;
|
|
5497
|
+
let ry = y;
|
|
5498
|
+
let rh = layout.height;
|
|
5499
|
+
if (clipBounds) {
|
|
5500
|
+
const topClipped = Math.max(ry, clipBounds.top);
|
|
5501
|
+
const bottomClipped = Math.min(ry + rh, clipBounds.bottom);
|
|
5502
|
+
if (topClipped >= bottomClipped) return false;
|
|
5503
|
+
ry = topClipped;
|
|
5504
|
+
rh = bottomClipped - topClipped;
|
|
5505
|
+
if (clipBounds.left !== void 0 && clipBounds.right !== void 0) {
|
|
5506
|
+
const leftClipped = Math.max(rx, clipBounds.left);
|
|
5507
|
+
const rightClipped = Math.min(rx + rw, clipBounds.right);
|
|
5508
|
+
if (leftClipped >= rightClipped) return false;
|
|
5509
|
+
rx = leftClipped;
|
|
5510
|
+
rw = rightClipped - leftClipped;
|
|
5511
|
+
}
|
|
5512
|
+
}
|
|
5513
|
+
sink.emitMergeAttrs(rx, ry, rw, rh, overlay.attrs, overlay.underlineColor);
|
|
5514
|
+
return true;
|
|
5515
|
+
}
|
|
5516
|
+
/**
|
|
5517
|
+
* Check whether any transparent sibling of this scroll container is an
|
|
5518
|
+
* absolute-positioned Box whose rect overlaps with the scroll container's rect.
|
|
5519
|
+
*
|
|
5520
|
+
* This matters for Tier 1 (buffer shift) safety: when a transparent sibling
|
|
5521
|
+
* absolute child paints into our rect (e.g., an overlay glyph at col=width-1),
|
|
5522
|
+
* the shift would smear those pixels along with the content. Opaque absolute
|
|
5523
|
+
* siblings are safe because the normal third pass always re-renders absolute
|
|
5524
|
+
* children after the scroll container and repaints their whole rect.
|
|
5525
|
+
*
|
|
5526
|
+
* O(siblings) — typically 1-3 siblings under common parents (ListView outer
|
|
5527
|
+
* Box has scroll container + scrollbar + edge-bump indicator). Returns false
|
|
5528
|
+
* for the common case (no parent or no absolute siblings).
|
|
5529
|
+
*/
|
|
5530
|
+
function checkOverlappingAbsoluteSibling(node, scrollRect) {
|
|
5531
|
+
const parent = node.parent;
|
|
5532
|
+
if (!parent) return false;
|
|
5533
|
+
const siblings = parent.children;
|
|
5534
|
+
if (!siblings) return false;
|
|
5535
|
+
const sLeft = scrollRect.x;
|
|
5536
|
+
const sTop = scrollRect.y;
|
|
5537
|
+
const sRight = scrollRect.x + scrollRect.width;
|
|
5538
|
+
const sBottom = scrollRect.y + scrollRect.height;
|
|
5539
|
+
for (const sib of siblings) {
|
|
5540
|
+
if (sib === node) continue;
|
|
5541
|
+
const sp = sib.props;
|
|
5542
|
+
if (sp?.position !== "absolute") continue;
|
|
5543
|
+
if (getEffectiveBg(sp)) continue;
|
|
5544
|
+
const r = sib.boxRect;
|
|
5545
|
+
if (!r) continue;
|
|
5546
|
+
if (r.x < sRight && r.x + r.width > sLeft && r.y < sBottom && r.y + r.height > sTop) return true;
|
|
5547
|
+
}
|
|
5548
|
+
return false;
|
|
5549
|
+
}
|
|
5550
|
+
/**
|
|
3530
5551
|
* Determine the scroll tier strategy for this frame.
|
|
3531
5552
|
*
|
|
3532
5553
|
* Pure function -- no side effects, no node access beyond the inputs.
|
|
@@ -3534,15 +5555,15 @@ function renderOwnContent(node, buffer, layout, props, nodeState, skipBgFill, in
|
|
|
3534
5555
|
* Three-tier strategy:
|
|
3535
5556
|
* 1. **shift**: Only scroll offset changed, no sticky children. Buffer contents
|
|
3536
5557
|
* shifted by scroll delta; only newly visible edges re-render.
|
|
3537
|
-
* 2. **clear**: Children restructured,
|
|
3538
|
-
*
|
|
5558
|
+
* 2. **clear**: Children restructured, sticky/overlaid scroll, or parent region
|
|
5559
|
+
* changed. Entire viewport cleared and all children re-render.
|
|
3539
5560
|
* 3. **subtree-only**: Only some descendants changed. Children use hasPrevBuffer=true
|
|
3540
5561
|
* and skip via fast-path if clean. With sticky children, forces all first-pass
|
|
3541
5562
|
* items to re-render (stickyForceRefresh).
|
|
3542
5563
|
*/
|
|
3543
5564
|
function planScrollRender(inputs) {
|
|
3544
|
-
const { scrollOffsetChanged, visibleRangeChanged, hasStickyChildren, childrenNeedFreshRender, childrenDirty, hasPrevBuffer, ancestorCleared, contentRegionCleared, scrollBg } = inputs;
|
|
3545
|
-
const scrollOnly = hasPrevBuffer && scrollOffsetChanged && !childrenDirty && !childrenNeedFreshRender && !hasStickyChildren && !
|
|
5565
|
+
const { scrollOffsetChanged, visibleRangeChanged, hasStickyChildren, childrenNeedFreshRender, childrenDirty, hasPrevBuffer, ancestorCleared, contentRegionCleared, scrollBg, hasOverlappingAbsoluteSibling = false } = inputs;
|
|
5566
|
+
const scrollOnly = hasPrevBuffer && scrollOffsetChanged && !childrenDirty && !childrenNeedFreshRender && !hasStickyChildren && !hasOverlappingAbsoluteSibling;
|
|
3546
5567
|
const needsViewportClear = hasPrevBuffer && !scrollOnly && (scrollOffsetChanged || childrenDirty || childrenNeedFreshRender || visibleRangeChanged);
|
|
3547
5568
|
const stickyForceRefresh = hasStickyChildren && hasPrevBuffer && !needsViewportClear;
|
|
3548
5569
|
const reasons = [];
|
|
@@ -3552,6 +5573,7 @@ function planScrollRender(inputs) {
|
|
|
3552
5573
|
if (childrenDirty) reasons.push("childrenDirty");
|
|
3553
5574
|
if (childrenNeedFreshRender) reasons.push("childrenNeedFreshRender");
|
|
3554
5575
|
if (visibleRangeChanged) reasons.push("visibleRangeChanged");
|
|
5576
|
+
if (hasOverlappingAbsoluteSibling) reasons.push("overlappingAbsoluteSibling");
|
|
3555
5577
|
}
|
|
3556
5578
|
if (stickyForceRefresh) reasons.push("stickyForceRefresh");
|
|
3557
5579
|
return {
|
|
@@ -3566,8 +5588,8 @@ function planScrollRender(inputs) {
|
|
|
3566
5588
|
/**
|
|
3567
5589
|
* Render children of a scroll container with proper clipping and offset.
|
|
3568
5590
|
*/
|
|
3569
|
-
function renderScrollContainerChildren(node, buffer, props, nodeState, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
|
|
3570
|
-
const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg } = nodeState;
|
|
5591
|
+
function renderScrollContainerChildren(node, buffer, sink, props, nodeState, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
|
|
5592
|
+
const { clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg, selectableMode } = nodeState;
|
|
3571
5593
|
const instr = resolveInstrumentation(ctx);
|
|
3572
5594
|
const layout = node.boxRect;
|
|
3573
5595
|
const ss = node.scrollState;
|
|
@@ -3593,6 +5615,7 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3593
5615
|
const contentX = layout.x + border.left + padding.left;
|
|
3594
5616
|
const contentWidth = layout.width - border.left - border.right - padding.left - padding.right;
|
|
3595
5617
|
const scrollBg = scrollOffsetChanged || isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childrenNeedFreshRender || visibleRangeChanged ? getEffectiveBg(props) ? parseColor(getEffectiveBg(props)) : inheritedBg.color : null;
|
|
5618
|
+
const hasOverlappingAbsoluteSibling = checkOverlappingAbsoluteSibling(node, layout);
|
|
3596
5619
|
const plan = planScrollRender({
|
|
3597
5620
|
scrollOffsetChanged,
|
|
3598
5621
|
visibleRangeChanged,
|
|
@@ -3602,7 +5625,8 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3602
5625
|
hasPrevBuffer,
|
|
3603
5626
|
ancestorCleared,
|
|
3604
5627
|
contentRegionCleared,
|
|
3605
|
-
scrollBg
|
|
5628
|
+
scrollBg,
|
|
5629
|
+
hasOverlappingAbsoluteSibling
|
|
3606
5630
|
});
|
|
3607
5631
|
const { tier, stickyForceRefresh } = plan;
|
|
3608
5632
|
const defaultChildHasPrev = plan.childHasPrev;
|
|
@@ -3623,28 +5647,16 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3623
5647
|
if (props.overflowIndicator === true && !border.top && !border.bottom) {
|
|
3624
5648
|
const topIndicatorY = clearY;
|
|
3625
5649
|
const bottomIndicatorY = clearY + clearHeight - 1;
|
|
3626
|
-
if (ss.prevOffset != null && ss.prevOffset > 0)
|
|
3627
|
-
|
|
3628
|
-
bg: plan.clearBg
|
|
3629
|
-
});
|
|
3630
|
-
buffer.fill(contentX, bottomIndicatorY, contentWidth, 1, {
|
|
3631
|
-
char: " ",
|
|
3632
|
-
bg: plan.clearBg
|
|
3633
|
-
});
|
|
5650
|
+
if (ss.prevOffset != null && ss.prevOffset > 0) sink.emitClearRect(contentX, topIndicatorY, contentWidth, 1, plan.clearBg);
|
|
5651
|
+
sink.emitClearRect(contentX, bottomIndicatorY, contentWidth, 1, plan.clearBg);
|
|
3634
5652
|
}
|
|
3635
|
-
|
|
5653
|
+
sink.emitScrollRegion(contentX, clearY, contentWidth, clearHeight, scrollDelta, {
|
|
3636
5654
|
char: " ",
|
|
3637
5655
|
bg: plan.clearBg
|
|
3638
5656
|
});
|
|
3639
5657
|
}
|
|
3640
|
-
if (tier === "clear" && clearHeight > 0)
|
|
3641
|
-
|
|
3642
|
-
bg: plan.clearBg
|
|
3643
|
-
});
|
|
3644
|
-
if (stickyForceRefresh && clearHeight > 0) buffer.fill(contentX, clearY, contentWidth, clearHeight, {
|
|
3645
|
-
char: " ",
|
|
3646
|
-
bg: null
|
|
3647
|
-
});
|
|
5658
|
+
if (tier === "clear" && clearHeight > 0) sink.emitClearRect(contentX, clearY, contentWidth, clearHeight, plan.clearBg);
|
|
5659
|
+
if (stickyForceRefresh && clearHeight > 0) sink.emitClearRect(contentX, clearY, contentWidth, clearHeight, null);
|
|
3648
5660
|
const childAncestorLayoutChanged = isCurrentEpoch(node.layoutChangedThisFrame) || !!ancestorLayoutChanged;
|
|
3649
5661
|
const prevVisTop = ss.prevOffset ?? ss.offset;
|
|
3650
5662
|
const prevVisBottom = prevVisTop + ss.viewportHeight;
|
|
@@ -3663,6 +5675,7 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3663
5675
|
const wasFullyVisible = childTop >= prevVisTop && childBottom <= prevVisBottom;
|
|
3664
5676
|
thisChildHasPrev = wasFullyVisible;
|
|
3665
5677
|
thisChildAncestorCleared = wasFullyVisible ? ancestorCleared || contentRegionCleared : true;
|
|
5678
|
+
if (!wasFullyVisible) clearShiftedNewChildRegion(childRect, ss.offset, childClipBounds, sink, plan.clearBg);
|
|
3666
5679
|
}
|
|
3667
5680
|
}
|
|
3668
5681
|
if (stickyForceRefresh && thisChildHasPrev) {
|
|
@@ -3670,7 +5683,7 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3670
5683
|
thisChildAncestorCleared = false;
|
|
3671
5684
|
}
|
|
3672
5685
|
if (canSkipChildSubtree(child, thisChildHasPrev, childAncestorLayoutChanged)) continue;
|
|
3673
|
-
renderNodeToBuffer(child, buffer, {
|
|
5686
|
+
renderNodeToBuffer(child, buffer, sink, {
|
|
3674
5687
|
scrollOffset: ss.offset,
|
|
3675
5688
|
clipBounds: childClipBounds,
|
|
3676
5689
|
hasPrevBuffer: thisChildHasPrev,
|
|
@@ -3678,13 +5691,14 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3678
5691
|
bufferIsCloned,
|
|
3679
5692
|
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3680
5693
|
inheritedBg,
|
|
3681
|
-
inheritedFg
|
|
5694
|
+
inheritedFg,
|
|
5695
|
+
selectableMode
|
|
3682
5696
|
}, ctx);
|
|
3683
5697
|
}
|
|
3684
5698
|
if (ss.stickyChildren) for (const sticky of ss.stickyChildren) {
|
|
3685
5699
|
const child = node.children[sticky.index];
|
|
3686
5700
|
if (!child?.boxRect) continue;
|
|
3687
|
-
renderNodeToBuffer(child, buffer, {
|
|
5701
|
+
renderNodeToBuffer(child, buffer, sink, {
|
|
3688
5702
|
scrollOffset: sticky.naturalTop - sticky.renderOffset,
|
|
3689
5703
|
clipBounds: childClipBounds,
|
|
3690
5704
|
hasPrevBuffer: false,
|
|
@@ -3692,15 +5706,42 @@ function renderScrollContainerChildren(node, buffer, props, nodeState, contentRe
|
|
|
3692
5706
|
bufferIsCloned,
|
|
3693
5707
|
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3694
5708
|
inheritedBg,
|
|
3695
|
-
inheritedFg
|
|
5709
|
+
inheritedFg,
|
|
5710
|
+
selectableMode
|
|
3696
5711
|
}, ctx);
|
|
3697
5712
|
}
|
|
3698
5713
|
}
|
|
3699
5714
|
/**
|
|
5715
|
+
* Tier-1 scroll shifting reuses previous-frame pixels. A child that was not fully
|
|
5716
|
+
* visible in the previous viewport is rendered like a fresh subtree, but the
|
|
5717
|
+
* shifted buffer under its transparent cells may still contain unrelated pixels
|
|
5718
|
+
* from a previous row. Clear the child's currently visible rectangle first so
|
|
5719
|
+
* transparent boxes/text render onto the same blank base as a fresh render.
|
|
5720
|
+
*/
|
|
5721
|
+
function clearShiftedNewChildRegion(childRect, scrollOffset, clipBounds, sink, bg) {
|
|
5722
|
+
let x = childRect.x;
|
|
5723
|
+
let y = childRect.y - scrollOffset;
|
|
5724
|
+
let width = childRect.width;
|
|
5725
|
+
let height = childRect.height;
|
|
5726
|
+
if (clipBounds) {
|
|
5727
|
+
if (clipBounds.left !== void 0 && x < clipBounds.left) {
|
|
5728
|
+
width -= clipBounds.left - x;
|
|
5729
|
+
x = clipBounds.left;
|
|
5730
|
+
}
|
|
5731
|
+
if (clipBounds.right !== void 0 && x + width > clipBounds.right) width = clipBounds.right - x;
|
|
5732
|
+
if (y < clipBounds.top) {
|
|
5733
|
+
height -= clipBounds.top - y;
|
|
5734
|
+
y = clipBounds.top;
|
|
5735
|
+
}
|
|
5736
|
+
if (y + height > clipBounds.bottom) height = clipBounds.bottom - y;
|
|
5737
|
+
}
|
|
5738
|
+
if (width > 0 && height > 0) sink.emitClearRect(x, y, width, height, bg);
|
|
5739
|
+
}
|
|
5740
|
+
/**
|
|
3700
5741
|
* Render children of a normal (non-scroll) container.
|
|
3701
5742
|
*/
|
|
3702
|
-
function renderNormalChildren(node, buffer, props, nodeState, childPositionChanged = false, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
|
|
3703
|
-
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg } = nodeState;
|
|
5743
|
+
function renderNormalChildren(node, buffer, sink, props, nodeState, childPositionChanged = false, contentRegionCleared = false, childrenNeedFreshRender = false, ctx) {
|
|
5744
|
+
const { scrollOffset, clipBounds, hasPrevBuffer, ancestorCleared, bufferIsCloned, ancestorLayoutChanged, inheritedBg, inheritedFg, selectableMode } = nodeState;
|
|
3704
5745
|
const instr = resolveInstrumentation(ctx);
|
|
3705
5746
|
const layout = node.boxRect;
|
|
3706
5747
|
if (!layout) return;
|
|
@@ -3735,10 +5776,7 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
|
|
|
3735
5776
|
}
|
|
3736
5777
|
if (clipBounds.right !== void 0 && clearX + clearW > clipBounds.right) clearW = clipBounds.right - clearX;
|
|
3737
5778
|
}
|
|
3738
|
-
if (clearW > 0 && clearH > 0)
|
|
3739
|
-
char: " ",
|
|
3740
|
-
bg: null
|
|
3741
|
-
});
|
|
5779
|
+
if (clearW > 0 && clearH > 0) sink.emitClearRect(clearX, clearY, clearW, clearH, null);
|
|
3742
5780
|
}
|
|
3743
5781
|
const childrenNeedRepaint = isDirty(node.dirtyBits, node.dirtyEpoch, 8) || childPositionChanged || childrenNeedFreshRender;
|
|
3744
5782
|
if (instr.enabled && childrenNeedRepaint && hasPrevBuffer) {
|
|
@@ -3757,6 +5795,7 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
|
|
|
3757
5795
|
childAncestorCleared = false;
|
|
3758
5796
|
}
|
|
3759
5797
|
let hasAbsoluteChildren = false;
|
|
5798
|
+
const firstPassChildren = [];
|
|
3760
5799
|
for (const child of node.children) {
|
|
3761
5800
|
const childProps = child.props;
|
|
3762
5801
|
if (childProps.position === "absolute") {
|
|
@@ -3764,22 +5803,46 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
|
|
|
3764
5803
|
continue;
|
|
3765
5804
|
}
|
|
3766
5805
|
if (hasStickyChildren && childProps.position === "sticky") continue;
|
|
3767
|
-
|
|
3768
|
-
|
|
5806
|
+
firstPassChildren.push(child);
|
|
5807
|
+
}
|
|
5808
|
+
const willRender = firstPassChildren.map((child) => !canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged));
|
|
5809
|
+
if (childHasPrev) for (let i = 0; i < firstPassChildren.length; i++) {
|
|
5810
|
+
if (!willRender[i]) continue;
|
|
5811
|
+
const childI = firstPassChildren[i];
|
|
5812
|
+
const ei = computeSubtreePaintExtent(childI);
|
|
5813
|
+
if (!ei || ei.width <= 0 || ei.height <= 0) continue;
|
|
5814
|
+
for (let j = i + 1; j < firstPassChildren.length; j++) {
|
|
5815
|
+
if (willRender[j]) continue;
|
|
5816
|
+
const rj = firstPassChildren[j].boxRect;
|
|
5817
|
+
if (!rj || rj.width <= 0 || rj.height <= 0) continue;
|
|
5818
|
+
const overlapX = ei.x < rj.x + rj.width && rj.x < ei.x + ei.width;
|
|
5819
|
+
const overlapY = ei.y < rj.y + rj.height && rj.y < ei.y + ei.height;
|
|
5820
|
+
if (overlapX && overlapY) {
|
|
5821
|
+
willRender[j] = true;
|
|
5822
|
+
if (instr.enabled) instr.stats.siblingOverlapForced++;
|
|
5823
|
+
}
|
|
5824
|
+
}
|
|
5825
|
+
}
|
|
5826
|
+
for (let i = 0; i < firstPassChildren.length; i++) {
|
|
5827
|
+
if (!willRender[i]) continue;
|
|
5828
|
+
const child = firstPassChildren[i];
|
|
5829
|
+
const overlapForced = !!canSkipChildSubtree(child, childHasPrev, childAncestorLayoutChanged);
|
|
5830
|
+
renderNodeToBuffer(child, buffer, sink, {
|
|
3769
5831
|
scrollOffset,
|
|
3770
5832
|
clipBounds: effectiveClipBounds,
|
|
3771
|
-
hasPrevBuffer: childHasPrev,
|
|
3772
|
-
ancestorCleared: childAncestorCleared,
|
|
5833
|
+
hasPrevBuffer: overlapForced ? false : childHasPrev,
|
|
5834
|
+
ancestorCleared: overlapForced ? false : childAncestorCleared,
|
|
3773
5835
|
bufferIsCloned,
|
|
3774
5836
|
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3775
5837
|
inheritedBg,
|
|
3776
|
-
inheritedFg
|
|
5838
|
+
inheritedFg,
|
|
5839
|
+
selectableMode
|
|
3777
5840
|
}, ctx);
|
|
3778
5841
|
}
|
|
3779
5842
|
if (node.stickyChildren) for (const sticky of node.stickyChildren) {
|
|
3780
5843
|
const child = node.children[sticky.index];
|
|
3781
5844
|
if (!child?.boxRect) continue;
|
|
3782
|
-
renderNodeToBuffer(child, buffer, {
|
|
5845
|
+
renderNodeToBuffer(child, buffer, sink, {
|
|
3783
5846
|
scrollOffset: sticky.naturalTop - sticky.renderOffset,
|
|
3784
5847
|
clipBounds: effectiveClipBounds,
|
|
3785
5848
|
hasPrevBuffer: false,
|
|
@@ -3787,12 +5850,13 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
|
|
|
3787
5850
|
bufferIsCloned,
|
|
3788
5851
|
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3789
5852
|
inheritedBg,
|
|
3790
|
-
inheritedFg
|
|
5853
|
+
inheritedFg,
|
|
5854
|
+
selectableMode
|
|
3791
5855
|
}, ctx);
|
|
3792
5856
|
}
|
|
3793
5857
|
if (hasAbsoluteChildren) for (const child of node.children) {
|
|
3794
5858
|
if (child.props.position !== "absolute") continue;
|
|
3795
|
-
renderNodeToBuffer(child, buffer, {
|
|
5859
|
+
renderNodeToBuffer(child, buffer, sink, {
|
|
3796
5860
|
scrollOffset,
|
|
3797
5861
|
clipBounds: effectiveClipBounds,
|
|
3798
5862
|
hasPrevBuffer: false,
|
|
@@ -3800,11 +5864,51 @@ function renderNormalChildren(node, buffer, props, nodeState, childPositionChang
|
|
|
3800
5864
|
bufferIsCloned,
|
|
3801
5865
|
ancestorLayoutChanged: childAncestorLayoutChanged,
|
|
3802
5866
|
inheritedBg,
|
|
3803
|
-
inheritedFg
|
|
5867
|
+
inheritedFg,
|
|
5868
|
+
selectableMode
|
|
3804
5869
|
}, ctx);
|
|
3805
5870
|
}
|
|
3806
5871
|
}
|
|
3807
5872
|
/**
|
|
5873
|
+
* Compute the screen-space rectangle that bounds every paint within this
|
|
5874
|
+
* subtree. Used by sibling-overlap detection in renderNormalChildren so
|
|
5875
|
+
* that a descendant overflowing its ancestor still triggers force-repaint
|
|
5876
|
+
* of any later sibling at the painted row.
|
|
5877
|
+
*
|
|
5878
|
+
* Returns the union of `node.boxRect` with the recursive paint extent of
|
|
5879
|
+
* each child whose `boxRect` is set. Returns null if `node.boxRect` is
|
|
5880
|
+
* unset (e.g. abandoned subtree). Skipping descendants without a boxRect
|
|
5881
|
+
* is safe because they never paint.
|
|
5882
|
+
*
|
|
5883
|
+
* Cost: O(N) per call where N is the subtree's node count. Only called
|
|
5884
|
+
* for first-pass children that will render AND only when their parent has
|
|
5885
|
+
* a prev buffer (i.e. real incremental render frames). Skipped children
|
|
5886
|
+
* never trigger the walk.
|
|
5887
|
+
*/
|
|
5888
|
+
function computeSubtreePaintExtent(node) {
|
|
5889
|
+
const own = node.boxRect;
|
|
5890
|
+
if (!own) return null;
|
|
5891
|
+
let minX = own.x;
|
|
5892
|
+
let minY = own.y;
|
|
5893
|
+
let maxX = own.x + own.width;
|
|
5894
|
+
let maxY = own.y + own.height;
|
|
5895
|
+
const children = node.children;
|
|
5896
|
+
if (children) for (const child of children) {
|
|
5897
|
+
const sub = computeSubtreePaintExtent(child);
|
|
5898
|
+
if (!sub) continue;
|
|
5899
|
+
if (sub.x < minX) minX = sub.x;
|
|
5900
|
+
if (sub.y < minY) minY = sub.y;
|
|
5901
|
+
if (sub.x + sub.width > maxX) maxX = sub.x + sub.width;
|
|
5902
|
+
if (sub.y + sub.height > maxY) maxY = sub.y + sub.height;
|
|
5903
|
+
}
|
|
5904
|
+
return {
|
|
5905
|
+
x: minX,
|
|
5906
|
+
y: minY,
|
|
5907
|
+
width: maxX - minX,
|
|
5908
|
+
height: maxY - minY
|
|
5909
|
+
};
|
|
5910
|
+
}
|
|
5911
|
+
/**
|
|
3808
5912
|
* O(1) pre-check: can we skip calling renderNodeToBuffer() on this child entirely?
|
|
3809
5913
|
*
|
|
3810
5914
|
* This is the Phase 4 "dirty set rendering" optimization. Instead of calling
|
|
@@ -3950,15 +6054,15 @@ function computeChildClipBounds(layout, props, parentClip, scrollOffset = 0, hor
|
|
|
3950
6054
|
* Only clears OUTSIDE the node's rect — interior clearing is handled by clearNodeRegion
|
|
3951
6055
|
* and renderBox. Recursive: follows subtreeDirty paths to find all overflowing descendants.
|
|
3952
6056
|
*/
|
|
3953
|
-
function clearDescendantOverflowRegions(node, buffer, layout, scrollOffset, clipBounds, threadedInheritedBg) {
|
|
6057
|
+
function clearDescendantOverflowRegions(node, buffer, sink, layout, scrollOffset, clipBounds, threadedInheritedBg) {
|
|
3954
6058
|
const clearBg = threadedInheritedBg.color;
|
|
3955
6059
|
const nodeRight = layout.x + layout.width;
|
|
3956
6060
|
const nodeBottom = layout.y - scrollOffset + layout.height;
|
|
3957
6061
|
const nodeLeft = layout.x;
|
|
3958
6062
|
const nodeTop = layout.y - scrollOffset;
|
|
3959
|
-
_clearDescendantOverflow(node.children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
|
|
6063
|
+
_clearDescendantOverflow(node.children, buffer, sink, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
|
|
3960
6064
|
}
|
|
3961
|
-
function _clearDescendantOverflow(children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg) {
|
|
6065
|
+
function _clearDescendantOverflow(children, buffer, sink, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg) {
|
|
3962
6066
|
for (const child of children) {
|
|
3963
6067
|
if (child.prevLayout && isCurrentEpoch(child.layoutChangedThisFrame)) {
|
|
3964
6068
|
const prev = child.prevLayout;
|
|
@@ -3970,53 +6074,51 @@ function _clearDescendantOverflow(children, buffer, nodeLeft, nodeTop, nodeRight
|
|
|
3970
6074
|
const overflowWidth = Math.min(prevRight, buffer.width) - overflowX;
|
|
3971
6075
|
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
|
|
3972
6076
|
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
|
|
3973
|
-
if (overflowWidth > 0 && overflowBottom > overflowTop)
|
|
3974
|
-
char: " ",
|
|
3975
|
-
bg: clearBg
|
|
3976
|
-
});
|
|
6077
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
|
|
3977
6078
|
}
|
|
3978
6079
|
if (prevBottom > nodeBottom) {
|
|
3979
6080
|
const overflowTop = Math.max(nodeBottom, clipBounds?.top ?? 0);
|
|
3980
6081
|
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
|
|
3981
6082
|
const overflowX = Math.max(prev.x, clipBounds?.left ?? 0);
|
|
3982
6083
|
const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX;
|
|
3983
|
-
if (overflowWidth > 0 && overflowBottom > overflowTop)
|
|
3984
|
-
char: " ",
|
|
3985
|
-
bg: clearBg
|
|
3986
|
-
});
|
|
6084
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
|
|
3987
6085
|
}
|
|
3988
6086
|
if (prev.x < nodeLeft) {
|
|
3989
6087
|
const overflowX = Math.max(prev.x, 0);
|
|
3990
6088
|
const overflowWidth = Math.min(nodeLeft, buffer.width) - overflowX;
|
|
3991
6089
|
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
|
|
3992
6090
|
const overflowBottom = Math.min(prevBottom, clipBounds?.bottom ?? buffer.height);
|
|
3993
|
-
if (overflowWidth > 0 && overflowBottom > overflowTop)
|
|
3994
|
-
char: " ",
|
|
3995
|
-
bg: clearBg
|
|
3996
|
-
});
|
|
6091
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
|
|
3997
6092
|
}
|
|
3998
6093
|
if (prevTop < nodeTop) {
|
|
3999
6094
|
const overflowTop = Math.max(prevTop, clipBounds?.top ?? 0);
|
|
4000
6095
|
const overflowBottom = Math.min(nodeTop, clipBounds?.bottom ?? buffer.height);
|
|
4001
6096
|
const overflowX = Math.max(prev.x, clipBounds?.left ?? 0);
|
|
4002
6097
|
const overflowWidth = Math.min(prevRight, clipBounds?.right ?? buffer.width) - overflowX;
|
|
4003
|
-
if (overflowWidth > 0 && overflowBottom > overflowTop)
|
|
4004
|
-
char: " ",
|
|
4005
|
-
bg: clearBg
|
|
4006
|
-
});
|
|
6098
|
+
if (overflowWidth > 0 && overflowBottom > overflowTop) sink.emitClearRect(overflowX, overflowTop, overflowWidth, overflowBottom - overflowTop, clearBg);
|
|
4007
6099
|
}
|
|
4008
6100
|
}
|
|
4009
|
-
if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) _clearDescendantOverflow(child.children, buffer, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
|
|
6101
|
+
if (isDirty(child.dirtyBits, child.dirtyEpoch, 16) && child.children !== void 0) _clearDescendantOverflow(child.children, buffer, sink, nodeLeft, nodeTop, nodeRight, nodeBottom, scrollOffset, clipBounds, clearBg);
|
|
4010
6102
|
}
|
|
4011
6103
|
}
|
|
4012
6104
|
/**
|
|
4013
|
-
* Clear a node's region with inherited bg when it has no backgroundColor.
|
|
4014
|
-
*
|
|
6105
|
+
* Clear a node's CURRENT region with inherited bg when it has no backgroundColor.
|
|
6106
|
+
*
|
|
6107
|
+
* Single responsibility: fill the node's `layout` rect with `clearBg`,
|
|
6108
|
+
* clipped to the parent's bounds and to the colored ancestor's bounds.
|
|
6109
|
+
*
|
|
6110
|
+
* Smell #3 (paint-clear-step2-followups) — pre-decouple, this function
|
|
6111
|
+
* tail-called `clearExcessArea` unconditionally. That meant excess
|
|
6112
|
+
* clearing could fire on a fresh buffer when contentRegionCleared=true
|
|
6113
|
+
* but bufferIsCloned/hasPrevBuffer were false. Post-decouple,
|
|
6114
|
+
* `executeRegionClearing` is the SOLE coordinator: it calls
|
|
6115
|
+
* `clearNodeRegion` AND `clearExcessArea` independently when their
|
|
6116
|
+
* respective gates are true.
|
|
4015
6117
|
*
|
|
4016
6118
|
* Clipping: clips to parent's boxRect (prevents overflow) and to the
|
|
4017
6119
|
* colored ancestor's bounds (prevents bg color bleeding into siblings).
|
|
4018
6120
|
*/
|
|
4019
|
-
function clearNodeRegion(node,
|
|
6121
|
+
function clearNodeRegion(node, sink, layout, scrollOffset, clipBounds, threadedInheritedBg) {
|
|
4020
6122
|
const inherited = threadedInheritedBg;
|
|
4021
6123
|
const clearBg = inherited.color;
|
|
4022
6124
|
const screenY = layout.y - scrollOffset;
|
|
@@ -4051,33 +6153,51 @@ function clearNodeRegion(node, buffer, layout, scrollOffset, clipBounds, layoutC
|
|
|
4051
6153
|
_cellDbg2.log.push(msg);
|
|
4052
6154
|
cellLog.debug?.(msg);
|
|
4053
6155
|
}
|
|
4054
|
-
|
|
4055
|
-
char: " ",
|
|
4056
|
-
bg: clearBg
|
|
4057
|
-
});
|
|
6156
|
+
sink.emitClearRect(clearX, clearY, clearWidth, clearHeight, clearBg);
|
|
4058
6157
|
}
|
|
4059
|
-
|
|
6158
|
+
}
|
|
6159
|
+
/**
|
|
6160
|
+
* Construct an `ExcessClearGate` if and only if all four preconditions hold.
|
|
6161
|
+
* Returns null otherwise. This is the SOLE constructor for the brand.
|
|
6162
|
+
*
|
|
6163
|
+
* Preconditions (each enforces one structural invariant):
|
|
6164
|
+
* - bufferIsCloned: there are stale pixels to clear (false for fresh buffers).
|
|
6165
|
+
* - layoutChanged: the node's rect changed this frame (no shrink → no excess).
|
|
6166
|
+
* - prevLayout != null: we have an old rect to compute the excess from.
|
|
6167
|
+
* - hasPrevBuffer: the buffer at this node's position contains the *previous
|
|
6168
|
+
* frame's* pixels (not first-pass-painted current-frame siblings — the
|
|
6169
|
+
* latter is what second-pass absolute/sticky/overlap-forced dispatch sites
|
|
6170
|
+
* pass with hasPrevBuffer=false). Excess-clearing on top of fresh sibling
|
|
6171
|
+
* paint corrupts the in-progress frame; see km-silvery.ai-chat-incremental-mismatch.
|
|
6172
|
+
*/
|
|
6173
|
+
function requireExcessClearGate(bufferIsCloned, layoutChanged, prevLayout, hasPrevBuffer) {
|
|
6174
|
+
if (!bufferIsCloned) return null;
|
|
6175
|
+
if (!layoutChanged) return null;
|
|
6176
|
+
if (prevLayout == null) return null;
|
|
6177
|
+
if (!hasPrevBuffer) return null;
|
|
6178
|
+
return { prevLayout };
|
|
4060
6179
|
}
|
|
4061
6180
|
/**
|
|
4062
6181
|
* Clear the excess area when a node shrinks (old bounds were larger than new).
|
|
4063
6182
|
*
|
|
4064
|
-
*
|
|
4065
|
-
*
|
|
4066
|
-
*
|
|
4067
|
-
*
|
|
4068
|
-
*
|
|
4069
|
-
*
|
|
6183
|
+
* Requires an `ExcessClearGate` as the first parameter — only obtainable via
|
|
6184
|
+
* `requireExcessClearGate`, which checks the four preconditions. This makes
|
|
6185
|
+
* the wrong-order second-pass call site (the bug that motivated silvery
|
|
6186
|
+
* 168b4989) structurally unrepresentable: a caller cannot write
|
|
6187
|
+
* `clearExcessArea(...)` without first flowing through the factory, which
|
|
6188
|
+
* returns null when hasPrevBuffer=false (the second-pass case).
|
|
4070
6189
|
*
|
|
4071
|
-
* Clips to the COLORED ANCESTOR's content area (not immediate parent's full
|
|
4072
|
-
* to prevent inherited color from bleeding into sibling areas with
|
|
6190
|
+
* Clips to the COLORED ANCESTOR's content area (not immediate parent's full
|
|
6191
|
+
* rect) to prevent inherited color from bleeding into sibling areas with
|
|
6192
|
+
* different bg.
|
|
4073
6193
|
*
|
|
4074
6194
|
* IMPORTANT: Uses content area (inside border/padding), not full boxRect.
|
|
4075
6195
|
* Without this, excess clearing of a child that previously filled the parent's
|
|
4076
|
-
* content area will extend into the parent's border row, overwriting border
|
|
6196
|
+
* content area will extend into the parent's border row, overwriting border
|
|
6197
|
+
* chars.
|
|
4077
6198
|
*/
|
|
4078
|
-
function clearExcessArea(node, buffer, layout, scrollOffset, clipBounds,
|
|
4079
|
-
|
|
4080
|
-
const prev = node.prevLayout;
|
|
6199
|
+
function clearExcessArea(gate, node, buffer, sink, layout, scrollOffset, clipBounds, inherited) {
|
|
6200
|
+
const prev = gate.prevLayout;
|
|
4081
6201
|
const _cellDbg3 = getCellDebug();
|
|
4082
6202
|
const _prevCoversCell3 = _cellDbg3 && cellCoversPoint(_cellDbg3, prev.x, prev.y - scrollOffset, prev.width, prev.height);
|
|
4083
6203
|
if (prev.width <= layout.width && prev.height <= layout.height) {
|
|
@@ -4117,16 +6237,16 @@ function clearExcessArea(node, buffer, layout, scrollOffset, clipBounds, layoutC
|
|
|
4117
6237
|
const excessX = layout.x + layout.width;
|
|
4118
6238
|
let excessWidth = prev.width - layout.width;
|
|
4119
6239
|
if (excessX + excessWidth > clipRectRight) excessWidth = Math.max(0, clipRectRight - excessX);
|
|
4120
|
-
if (excessWidth > 0) clippedFill(
|
|
6240
|
+
if (excessWidth > 0) clippedFill(sink, excessX, excessWidth, prevScreenY, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
|
|
4121
6241
|
}
|
|
4122
6242
|
if (prev.height > layout.height) {
|
|
4123
6243
|
let bottomWidth = prev.width;
|
|
4124
6244
|
if (layout.x + bottomWidth > clipRectRight) bottomWidth = Math.max(0, clipRectRight - layout.x);
|
|
4125
|
-
clippedFill(
|
|
6245
|
+
clippedFill(sink, layout.x, bottomWidth, screenY + layout.height, prevScreenY + prev.height, clipBounds, clipRectBottom, clearBg);
|
|
4126
6246
|
}
|
|
4127
6247
|
}
|
|
4128
6248
|
/** Fill a rectangular region, clipping to clipBounds and an outer bottom limit. */
|
|
4129
|
-
function clippedFill(
|
|
6249
|
+
function clippedFill(sink, x, width, top, bottom, clipBounds, outerBottom, bg) {
|
|
4130
6250
|
const clippedTop = clipBounds ? Math.max(top, clipBounds.top) : top;
|
|
4131
6251
|
const clippedBottom = Math.min(clipBounds ? Math.min(bottom, clipBounds.bottom) : bottom, outerBottom);
|
|
4132
6252
|
let clippedX = x;
|
|
@@ -4139,10 +6259,98 @@ function clippedFill(buffer, x, width, top, bottom, clipBounds, outerBottom, bg)
|
|
|
4139
6259
|
if (clippedX + clippedWidth > clipBounds.right) clippedWidth = Math.max(0, clipBounds.right - clippedX);
|
|
4140
6260
|
}
|
|
4141
6261
|
const height = clippedBottom - clippedTop;
|
|
4142
|
-
if (height > 0 && clippedWidth > 0)
|
|
6262
|
+
if (height > 0 && clippedWidth > 0) sink.emitClearRect(clippedX, clippedTop, clippedWidth, height, bg);
|
|
6263
|
+
}
|
|
6264
|
+
//#endregion
|
|
6265
|
+
//#region packages/ag-term/src/pipeline/render-plan.ts
|
|
6266
|
+
init_buffer();
|
|
6267
|
+
/**
|
|
6268
|
+
* Read the SILVERY_RENDER_PLAN env var. Returns true (default) UNLESS
|
|
6269
|
+
* the user explicitly opts out with `SILVERY_RENDER_PLAN=0` /
|
|
6270
|
+
* `SILVERY_RENDER_PLAN=false`.
|
|
6271
|
+
*
|
|
6272
|
+
* Phase 1: opt-in only (default off).
|
|
6273
|
+
* Phase 2: opt-in via flag for testing.
|
|
6274
|
+
* Phase 3 (current): default ON, opt-out for one release. The plan
|
|
6275
|
+
* capture + sectioned commit is now load-bearing — it runs every
|
|
6276
|
+
* frame, the captured plan is committed onto a fresh clone for parity
|
|
6277
|
+
* (verified by tests/features/render-plan-fuzz.test.tsx +
|
|
6278
|
+
* tests/features/render-plan-production.test.tsx). The opt-out exists
|
|
6279
|
+
* solely to roll back if a regression slips through; the next release
|
|
6280
|
+
* removes the env var entirely.
|
|
6281
|
+
*/
|
|
6282
|
+
function isRenderPlanEnabled() {
|
|
6283
|
+
const v = (typeof process !== "undefined" ? process.env : void 0)?.SILVERY_RENDER_PLAN;
|
|
6284
|
+
if (v === void 0) return true;
|
|
6285
|
+
const lower = v.toLowerCase();
|
|
6286
|
+
return lower !== "0" && lower !== "false" && lower !== "" && lower !== "off" && lower !== "no";
|
|
6287
|
+
}
|
|
6288
|
+
/**
|
|
6289
|
+
* Apply a sectioned plan to a target buffer in structural order:
|
|
6290
|
+
* transfer → cleanup → paint → overlay → postState. Within each section,
|
|
6291
|
+
* ops apply in plan-emission order (which preserves CSS paint order for
|
|
6292
|
+
* paintOps: later sibling bg can legitimately cover earlier sibling
|
|
6293
|
+
* overflow text).
|
|
6294
|
+
*
|
|
6295
|
+
* This is the L4 commit path: wrong-order sibling-stomp is unrepresentable
|
|
6296
|
+
* by construction because a `ClearOp` cannot end up in `paintOps` — the
|
|
6297
|
+
* type system rejects it.
|
|
6298
|
+
*/
|
|
6299
|
+
function commitSectionedPlan(target, plan) {
|
|
6300
|
+
if (target.width !== plan.width || target.height !== plan.height) throw new Error(`commitSectionedPlan: buffer dimensions ${target.width}x${target.height} do not match plan ${plan.width}x${plan.height}`);
|
|
6301
|
+
for (const op of plan.transferOps) applyTransfer(target, op);
|
|
6302
|
+
for (const op of plan.cleanupOps) applyClear(target, op);
|
|
6303
|
+
for (const op of plan.paintOps) applyPaint(target, op);
|
|
6304
|
+
for (const op of plan.overlayOps) applyOverlay(target, op);
|
|
6305
|
+
for (const op of plan.postStateOps) applyPostState(target, op);
|
|
6306
|
+
}
|
|
6307
|
+
function applyTransfer(buffer, op) {
|
|
6308
|
+
buffer.scrollRegion(op.x, op.y, op.width, op.height, op.delta, selectablePatch(op.clearCell ?? {}, op.selectable));
|
|
6309
|
+
}
|
|
6310
|
+
function applyClear(buffer, op) {
|
|
6311
|
+
if (op.kind === "clearRect") buffer.fill(op.x, op.y, op.width, op.height, {
|
|
4143
6312
|
char: " ",
|
|
4144
|
-
bg
|
|
6313
|
+
bg: op.bg,
|
|
6314
|
+
selectable: op.selectable
|
|
4145
6315
|
});
|
|
6316
|
+
else buffer.fill(op.x, op.y, op.width, op.height, op.cell);
|
|
6317
|
+
}
|
|
6318
|
+
function applyPaint(buffer, op) {
|
|
6319
|
+
switch (op.kind) {
|
|
6320
|
+
case "setCell":
|
|
6321
|
+
buffer.setCell(op.x, op.y, op.cell);
|
|
6322
|
+
return;
|
|
6323
|
+
case "fillBg":
|
|
6324
|
+
buffer.fillBg(op.x, op.y, op.width, op.height, op.bg);
|
|
6325
|
+
return;
|
|
6326
|
+
case "paintFill":
|
|
6327
|
+
buffer.fill(op.x, op.y, op.width, op.height, op.cell);
|
|
6328
|
+
return;
|
|
6329
|
+
case "restyleRegion":
|
|
6330
|
+
buffer.restyleRegion(op.x, op.y, op.width, op.height, op.style);
|
|
6331
|
+
return;
|
|
6332
|
+
}
|
|
6333
|
+
}
|
|
6334
|
+
function applyOverlay(buffer, op) {
|
|
6335
|
+
buffer.mergeAttrsInRect(op.x, op.y, op.width, op.height, op.attrs, op.underlineColor);
|
|
6336
|
+
}
|
|
6337
|
+
function applyPostState(buffer, op) {
|
|
6338
|
+
switch (op.kind) {
|
|
6339
|
+
case "setRowMeta": {
|
|
6340
|
+
const meta = {};
|
|
6341
|
+
if (op.softWrapped !== void 0) meta.softWrapped = op.softWrapped;
|
|
6342
|
+
if (op.lastContentCol !== void 0) meta.lastContentCol = op.lastContentCol;
|
|
6343
|
+
buffer.setRowMeta(op.row, meta);
|
|
6344
|
+
return;
|
|
6345
|
+
}
|
|
6346
|
+
case "setOutlineSnapshots": return;
|
|
6347
|
+
}
|
|
6348
|
+
}
|
|
6349
|
+
function selectablePatch(cell, selectable) {
|
|
6350
|
+
return selectable === void 0 ? cell : {
|
|
6351
|
+
...cell,
|
|
6352
|
+
selectable
|
|
6353
|
+
};
|
|
4146
6354
|
}
|
|
4147
6355
|
//#endregion
|
|
4148
6356
|
//#region packages/ag-term/src/pipeline/backdrop/color.ts
|
|
@@ -4207,12 +6415,15 @@ function normalizeHex(hex) {
|
|
|
4207
6415
|
/**
|
|
4208
6416
|
* Backdrop fade — stage 1: build the immutable `Plan`.
|
|
4209
6417
|
*
|
|
4210
|
-
* `buildPlan(root, options)` is a PURE
|
|
4211
|
-
*
|
|
4212
|
-
*
|
|
4213
|
-
*
|
|
4214
|
-
*
|
|
4215
|
-
*
|
|
6418
|
+
* `buildPlan(root, options)` is a PURE pass that walks the tree, collects
|
|
6419
|
+
* `data-backdrop-fade` / `data-backdrop-fade-excluded` markers, enforces
|
|
6420
|
+
* the single-amount invariant, and resolves the scrim + default colors.
|
|
6421
|
+
* It returns a `TerminalPlan` (`CorePlan` + `kittyEnabled`); call
|
|
6422
|
+
* `buildCorePlan` for a framework-agnostic plan that strips terminal
|
|
6423
|
+
* capability flags. The realizers (`./realize-buffer.ts`,
|
|
6424
|
+
* `./realize-kitty.ts`) trust the plan: they do NOT re-walk the tree,
|
|
6425
|
+
* re-resolve the scrim, or re-validate amounts. This module is the
|
|
6426
|
+
* single source of truth.
|
|
4216
6427
|
*
|
|
4217
6428
|
* ## The model: per-channel alpha scrim with perceptually-aware fg
|
|
4218
6429
|
*
|
|
@@ -4232,7 +6443,7 @@ function normalizeHex(hex) {
|
|
|
4232
6443
|
* Fg uses OKLCH deemphasize with explicit polarity so colored text
|
|
4233
6444
|
* deemphasizes toward the correct theme neutral — toward black on dark
|
|
4234
6445
|
* themes (same formula we've always used), toward white on light themes
|
|
4235
|
-
* (new — see `./color-
|
|
6446
|
+
* (new — see `./color-shim.ts` for the math). The quadratic chroma
|
|
4236
6447
|
* falloff compensates for the human-vision nonlinearity that reads chroma
|
|
4237
6448
|
* relative to luminance. Bg uses sRGB source-over because the Kitty
|
|
4238
6449
|
* graphics scrim overlay composites in sRGB at alpha at the hardware
|
|
@@ -4303,10 +6514,15 @@ const INACTIVE_PLAN = Object.freeze({
|
|
|
4303
6514
|
kittyEnabled: false
|
|
4304
6515
|
});
|
|
4305
6516
|
/**
|
|
4306
|
-
*
|
|
4307
|
-
*
|
|
4308
|
-
*
|
|
4309
|
-
*
|
|
6517
|
+
* Syntactic check: does the tree contain any `data-backdrop-fade*`
|
|
6518
|
+
* attribute? Returns true even when the marker amounts are 0 (which
|
|
6519
|
+
* yields an inactive plan). For "is the plan active this frame?" use
|
|
6520
|
+
* `buildPlan(...).active`.
|
|
6521
|
+
*
|
|
6522
|
+
* Used as a gate so we don't clone the buffer every frame when no
|
|
6523
|
+
* backdrop markers are mounted. Walks the full tree once (O(N)) — the
|
|
6524
|
+
* alternative (tracking dirty markers in the reconciler) is more complex
|
|
6525
|
+
* and the walk is cheap compared to the pass.
|
|
4310
6526
|
*/
|
|
4311
6527
|
function hasBackdropMarkers(root) {
|
|
4312
6528
|
const props = root.props;
|
|
@@ -4314,20 +6530,28 @@ function hasBackdropMarkers(root) {
|
|
|
4314
6530
|
for (const child of root.children) if (hasBackdropMarkers(child)) return true;
|
|
4315
6531
|
return false;
|
|
4316
6532
|
}
|
|
6533
|
+
Object.freeze({
|
|
6534
|
+
active: false,
|
|
6535
|
+
amount: 0,
|
|
6536
|
+
scrim: null,
|
|
6537
|
+
defaultBg: null,
|
|
6538
|
+
defaultFg: null,
|
|
6539
|
+
includes: Object.freeze([]),
|
|
6540
|
+
excludes: Object.freeze([]),
|
|
6541
|
+
mixedAmounts: false,
|
|
6542
|
+
scrimTowardLight: false
|
|
6543
|
+
});
|
|
4317
6544
|
/**
|
|
4318
|
-
* Stage 1 — build the immutable `
|
|
4319
|
-
*
|
|
4320
|
-
* Pure function of `(tree markers, options)`. No buffer access, no Kitty
|
|
4321
|
-
* capability knowledge, no console I/O. The realizers read from the plan
|
|
4322
|
-
* exclusively; the orchestrator (`./index.ts`) handles dev-mode diagnostics
|
|
4323
|
-
* derived from `plan.mixedAmounts`.
|
|
6545
|
+
* Stage 1 (terminal) — build the immutable `TerminalPlan`.
|
|
4324
6546
|
*
|
|
4325
|
-
*
|
|
4326
|
-
*
|
|
4327
|
-
*
|
|
6547
|
+
* Same as `buildCorePlan` plus the derived `kittyEnabled` capability flag.
|
|
6548
|
+
* `kittyEnabled = options.kittyGraphics === true && scrim !== null` — the
|
|
6549
|
+
* overlay needs a resolvable tint to composite, so a null-scrim plan
|
|
6550
|
+
* cannot use the Kitty path even when the caller has the capability
|
|
6551
|
+
* enabled.
|
|
4328
6552
|
*/
|
|
4329
6553
|
function buildPlan(root, options) {
|
|
4330
|
-
if ((options?.colorLevel ?? "truecolor") === "
|
|
6554
|
+
if ((options?.colorLevel ?? "truecolor") === "mono") return INACTIVE_PLAN;
|
|
4331
6555
|
const includes = [];
|
|
4332
6556
|
const excludes = [];
|
|
4333
6557
|
const includeAmounts = [];
|
|
@@ -4340,7 +6564,10 @@ function buildPlan(root, options) {
|
|
|
4340
6564
|
const scrimTowardLight = isLightScrim(scrim);
|
|
4341
6565
|
const defaultFg = normalizeHex(options?.defaultFg) ?? (scrim === null ? null : scrimTowardLight ? "#000000" : "#ffffff");
|
|
4342
6566
|
const { amount, hasMixedAmounts } = assertSingleAmount(includeAmounts, excludeAmounts);
|
|
4343
|
-
|
|
6567
|
+
const kittyEnabled = options?.kittyGraphics === true && scrim !== null;
|
|
6568
|
+
Object.freeze(includes);
|
|
6569
|
+
Object.freeze(excludes);
|
|
6570
|
+
return Object.freeze({
|
|
4344
6571
|
active: true,
|
|
4345
6572
|
amount,
|
|
4346
6573
|
scrim,
|
|
@@ -4350,8 +6577,8 @@ function buildPlan(root, options) {
|
|
|
4350
6577
|
excludes,
|
|
4351
6578
|
mixedAmounts: hasMixedAmounts,
|
|
4352
6579
|
scrimTowardLight,
|
|
4353
|
-
kittyEnabled
|
|
4354
|
-
};
|
|
6580
|
+
kittyEnabled
|
|
6581
|
+
});
|
|
4355
6582
|
}
|
|
4356
6583
|
/**
|
|
4357
6584
|
* Detect the single-amount invariant across all markers. Returns the
|
|
@@ -4411,16 +6638,22 @@ function collectBackdropMarkers(node, includes, excludes, includeAmounts, exclud
|
|
|
4411
6638
|
const includeRaw = props[BACKDROP_FADE_ATTR];
|
|
4412
6639
|
const excludeRaw = props[BACKDROP_FADE_EXCLUDE_ATTR];
|
|
4413
6640
|
if (includeRaw !== void 0 || excludeRaw !== void 0) {
|
|
4414
|
-
const
|
|
4415
|
-
if (
|
|
6641
|
+
const sourceRect = node.screenRect ?? node.scrollRect ?? node.boxRect;
|
|
6642
|
+
if (sourceRect && sourceRect.width > 0 && sourceRect.height > 0) {
|
|
6643
|
+
const rect = Object.freeze({
|
|
6644
|
+
x: sourceRect.x,
|
|
6645
|
+
y: sourceRect.y,
|
|
6646
|
+
width: sourceRect.width,
|
|
6647
|
+
height: sourceRect.height
|
|
6648
|
+
});
|
|
4416
6649
|
const inc = parseFade(includeRaw);
|
|
4417
6650
|
if (inc !== null) {
|
|
4418
|
-
includes.push({ rect });
|
|
6651
|
+
includes.push(Object.freeze({ rect }));
|
|
4419
6652
|
includeAmounts.push(inc);
|
|
4420
6653
|
}
|
|
4421
6654
|
const exc = parseFade(excludeRaw);
|
|
4422
6655
|
if (exc !== null) {
|
|
4423
|
-
excludes.push({ rect });
|
|
6656
|
+
excludes.push(Object.freeze({ rect }));
|
|
4424
6657
|
excludeAmounts.push(exc);
|
|
4425
6658
|
}
|
|
4426
6659
|
}
|
|
@@ -4459,48 +6692,23 @@ function parseFade(raw) {
|
|
|
4459
6692
|
return n > 1 ? 1 : n;
|
|
4460
6693
|
}
|
|
4461
6694
|
//#endregion
|
|
4462
|
-
//#region packages/ag-term/src/pipeline/backdrop/color-
|
|
6695
|
+
//#region packages/ag-term/src/pipeline/backdrop/color-shim.ts
|
|
4463
6696
|
/**
|
|
4464
|
-
* Backdrop fade — `@silvery/color`
|
|
6697
|
+
* Backdrop fade — `@silvery/color` polarity-aware deemphasize shim.
|
|
4465
6698
|
*
|
|
4466
|
-
*
|
|
4467
|
-
*
|
|
4468
|
-
*
|
|
4469
|
-
*
|
|
4470
|
-
* new helper is introduced in the same release cycle as the silvery
|
|
4471
|
-
* package that imports it (the published `@silvery/color` dist doesn't
|
|
4472
|
-
* ship the new name until its next publish, breaking CI verify).
|
|
6699
|
+
* `@silvery/color` exports `deemphasize` (dark-only, drifts L toward 0) and
|
|
6700
|
+
* `mixSrgb` directly. Use those whenever possible. The light-theme-aware
|
|
6701
|
+
* variant `deemphasizeOklchToward` (drifts L toward 0 OR 1 by polarity) is
|
|
6702
|
+
* NOT yet upstream — this file ships it locally.
|
|
4473
6703
|
*
|
|
4474
|
-
*
|
|
4475
|
-
*
|
|
4476
|
-
*
|
|
4477
|
-
* `
|
|
4478
|
-
*
|
|
4479
|
-
* Light-theme-aware deemphasize (`deemphasizeOklchToward`) is NOT in
|
|
4480
|
-
* upstream yet — it's only shipped by this module. When it lands in
|
|
4481
|
-
* `@silvery/color`, replace the local implementation with an upstream
|
|
4482
|
-
* re-export behind the same shim.
|
|
6704
|
+
* **Deletion condition**: when `@silvery/color` exports
|
|
6705
|
+
* `deemphasizeOklchToward`, delete this file entirely and update
|
|
6706
|
+
* `./realize-buffer.ts` to import directly from `@silvery/color`. Audit
|
|
6707
|
+
* with `rg "from .*color-shim"` — should drop to zero.
|
|
4483
6708
|
*
|
|
4484
6709
|
* @see ./color.ts for hex↔rgb adapter helpers and `HexColor` type.
|
|
4485
6710
|
*/
|
|
4486
6711
|
/**
|
|
4487
|
-
* sRGB source-over alpha mix. `out = a * (1 - t) + b * t`.
|
|
4488
|
-
*
|
|
4489
|
-
* Prefers `@silvery/color`'s published export; falls back to the local copy
|
|
4490
|
-
* when upstream doesn't ship the name yet. The local implementation matches
|
|
4491
|
-
* `@silvery/color/src/color.ts` byte-for-byte and is safe to use while the
|
|
4492
|
-
* publish train catches up.
|
|
4493
|
-
*/
|
|
4494
|
-
function localMixSrgb(a, b, t) {
|
|
4495
|
-
const ra = hexToRgb$1(a);
|
|
4496
|
-
const rb = hexToRgb$1(b);
|
|
4497
|
-
if (!ra || !rb) return a;
|
|
4498
|
-
const u = Math.max(0, Math.min(1, t));
|
|
4499
|
-
return rgbToHex(ra.r * (1 - u) + rb.r * u, ra.g * (1 - u) + rb.g * u, ra.b * (1 - u) + rb.b * u);
|
|
4500
|
-
}
|
|
4501
|
-
/** sRGB source-over mix. Prefers upstream `@silvery/color`; falls back to the local copy. */
|
|
4502
|
-
const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
|
|
4503
|
-
/**
|
|
4504
6712
|
* OKLCH-native deemphasize that drifts toward EITHER black (dark themes)
|
|
4505
6713
|
* OR white (light themes). `towardLight` controls the lightness target;
|
|
4506
6714
|
* the chroma falloff is identical in both directions.
|
|
@@ -4517,16 +6725,7 @@ const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
|
|
|
4517
6725
|
* the human visual system reads chroma RELATIVE to luminance, so a modest
|
|
4518
6726
|
* OKLCH C at extreme L *appears* distinctly more chromatic than the same C
|
|
4519
6727
|
* near mid-L. Proportional L+C scaling (`C *= 1-α`, preserving C/L) feels
|
|
4520
|
-
* "more saturated when darkened" to viewers
|
|
4521
|
-
* prompted the quadratic version.
|
|
4522
|
-
*
|
|
4523
|
-
* Using `(1-α)²` for chroma reduces saturation faster than lightness on
|
|
4524
|
-
* both polarities:
|
|
4525
|
-
*
|
|
4526
|
-
* α=0.25 → C *= 0.563 (C/L drops to 75% of original)
|
|
4527
|
-
* α=0.40 → C *= 0.360 (C/L drops to 60%)
|
|
4528
|
-
* α=0.50 → C *= 0.250 (C/L drops to 50%)
|
|
4529
|
-
* α=1.00 → C *= 0 (fully faded to the target luminance).
|
|
6728
|
+
* "more saturated when darkened" to viewers.
|
|
4530
6729
|
*
|
|
4531
6730
|
* Light-theme case (towardLight=true): a bright colored text on a light
|
|
4532
6731
|
* backdrop is made paler by raising L toward 1 and dropping C — the
|
|
@@ -4535,21 +6734,18 @@ const mixSrgb = Upstream.mixSrgb ?? localMixSrgb;
|
|
|
4535
6734
|
* would darken colored text on a light bg, which reads as "text popping"
|
|
4536
6735
|
* against the faded scrim rather than receding.
|
|
4537
6736
|
*/
|
|
4538
|
-
function
|
|
4539
|
-
const o =
|
|
6737
|
+
function deemphasizeOklchToward(hex, amount, towardLight) {
|
|
6738
|
+
const o = hexToOklch(hex);
|
|
4540
6739
|
if (!o) return hex;
|
|
4541
6740
|
const a = Math.max(0, Math.min(1, amount));
|
|
4542
6741
|
const chromaFactor = (1 - a) * (1 - a);
|
|
4543
6742
|
const L = towardLight ? o.L + (1 - o.L) * a : o.L * (1 - a);
|
|
4544
|
-
return
|
|
6743
|
+
return oklchToHex({
|
|
4545
6744
|
L: Math.max(0, Math.min(1, L)),
|
|
4546
6745
|
C: Math.max(0, o.C * chromaFactor),
|
|
4547
6746
|
H: o.H
|
|
4548
6747
|
});
|
|
4549
6748
|
}
|
|
4550
|
-
const upstreamHexToOklch = Upstream.hexToOklch;
|
|
4551
|
-
const upstreamOklchToHex = Upstream.oklchToHex;
|
|
4552
|
-
const deemphasizeOklchToward = Upstream.deemphasizeOklchToward ?? localDeemphasizeOklchToward;
|
|
4553
6749
|
//#endregion
|
|
4554
6750
|
//#region packages/ag-term/src/pipeline/backdrop/region.ts
|
|
4555
6751
|
/**
|
|
@@ -4568,7 +6764,7 @@ const deemphasizeOklchToward = Upstream.deemphasizeOklchToward ?? localDeemphasi
|
|
|
4568
6764
|
* Returns the count of unique cells visited. Useful for short-circuiting
|
|
4569
6765
|
* the "was any cell modified?" signal in realizers.
|
|
4570
6766
|
*/
|
|
4571
|
-
function
|
|
6767
|
+
function forEachBackdropCell(bufferWidth, bufferHeight, includes, excludes, visit) {
|
|
4572
6768
|
if (bufferWidth <= 0 || bufferHeight <= 0) return 0;
|
|
4573
6769
|
if (includes.length === 0 && excludes.length === 0) return 0;
|
|
4574
6770
|
const seen = new Uint8Array(bufferWidth * bufferHeight);
|
|
@@ -4617,9 +6813,47 @@ function forEachFadeRegionCell(bufferWidth, bufferHeight, includes, excludes, vi
|
|
|
4617
6813
|
//#endregion
|
|
4618
6814
|
//#region packages/ag-term/src/pipeline/backdrop/realize-buffer.ts
|
|
4619
6815
|
/**
|
|
6816
|
+
* Backdrop fade — stage 2a: apply the plan's cell-level transform to the
|
|
6817
|
+
* terminal buffer.
|
|
6818
|
+
*
|
|
6819
|
+
* Uses the shared `forEachBackdropCell` walker (`./region.ts`) to visit
|
|
6820
|
+
* every cell covered by the plan's include + exclude rects exactly once.
|
|
6821
|
+
* Trusts the plan: no marker re-collection, no scrim/default resolution,
|
|
6822
|
+
* no amount validation, no capability re-derivation (`plan.kittyEnabled`
|
|
6823
|
+
* is the sole source of truth for the emoji branch).
|
|
6824
|
+
*
|
|
6825
|
+
* Wide ≠ emoji. CJK / Hangul / Japanese fullwidth text occupies two columns
|
|
6826
|
+
* but responds to `fg` color normally — it goes through the standard mix
|
|
6827
|
+
* path. Only EMOJI (bitmap glyphs that ignore `fg`) need special handling,
|
|
6828
|
+
* detected via `isLikelyEmoji(cell.char)`.
|
|
6829
|
+
*
|
|
6830
|
+
* For emoji cells, two paths, mutually exclusive:
|
|
6831
|
+
*
|
|
6832
|
+
* 1. **Kitty graphics available** (`plan.kittyEnabled === true`): emoji cells
|
|
6833
|
+
* are SKIPPED entirely here. `./realize-kitty.ts` emits a translucent
|
|
6834
|
+
* scrim image at alpha=amount above each emoji cell, and the terminal
|
|
6835
|
+
* composites the overlay on top of the unmixed cell, landing at
|
|
6836
|
+
* `cell_bg * (1 - amount) + scrim * amount` — the same luminance as
|
|
6837
|
+
* surrounding text cells. This avoids the double-fade that would make
|
|
6838
|
+
* emoji bg visibly blacker.
|
|
6839
|
+
*
|
|
6840
|
+
* 2. **Kitty graphics unavailable** (`plan.kittyEnabled === false`): the
|
|
6841
|
+
* per-cell mix runs on emoji cells too and stamps `attrs.dim` (SGR 2)
|
|
6842
|
+
* on lead + continuation. Terminals honoring SGR 2 on emoji fade the
|
|
6843
|
+
* glyph; others see the glyph at full brightness but the cell bg
|
|
6844
|
+
* matches surroundings.
|
|
6845
|
+
*
|
|
6846
|
+
* @see ./plan.ts for the color model and scrim derivation.
|
|
6847
|
+
* @see ./color.ts for the hex↔rgb adapter helpers.
|
|
6848
|
+
* @see @silvery/color for `mixSrgb`.
|
|
6849
|
+
* @see ./color-shim.ts for `deemphasizeOklchToward` (polarity-aware,
|
|
6850
|
+
* not yet upstream).
|
|
6851
|
+
* @see ./region.ts for the shared include/exclude region walker.
|
|
6852
|
+
*/
|
|
6853
|
+
/**
|
|
4620
6854
|
* Stage 2a — apply the plan's cell-level transform to the buffer.
|
|
4621
6855
|
*
|
|
4622
|
-
* Walks every include + exclude cell once via `
|
|
6856
|
+
* Walks every include + exclude cell once via `forEachBackdropCell` and
|
|
4623
6857
|
* applies `fadeCell` with the plan's single `amount`. The buffer is mutated
|
|
4624
6858
|
* in place.
|
|
4625
6859
|
*
|
|
@@ -4635,7 +6869,7 @@ function realizeToBuffer(plan, buffer) {
|
|
|
4635
6869
|
if (!plan.active) return false;
|
|
4636
6870
|
if (plan.amount <= 0) return false;
|
|
4637
6871
|
let modified = false;
|
|
4638
|
-
|
|
6872
|
+
forEachBackdropCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
|
|
4639
6873
|
if (fadeCell(buffer, x, y, plan)) modified = true;
|
|
4640
6874
|
});
|
|
4641
6875
|
return modified;
|
|
@@ -4751,6 +6985,19 @@ function fadeCell(buffer, x, y, plan) {
|
|
|
4751
6985
|
if (fgHex && bgHex) {
|
|
4752
6986
|
const mixedRgb = hexToRgb$1(mixSrgb(fgHex, bgHex, amount));
|
|
4753
6987
|
if (!mixedRgb) return false;
|
|
6988
|
+
if (isEmojiGlyph) {
|
|
6989
|
+
const newAttrs = cell.attrs.dim ? cell.attrs : {
|
|
6990
|
+
...cell.attrs,
|
|
6991
|
+
dim: true
|
|
6992
|
+
};
|
|
6993
|
+
buffer.setCell(x, y, {
|
|
6994
|
+
...cell,
|
|
6995
|
+
fg: mixedRgb,
|
|
6996
|
+
attrs: newAttrs
|
|
6997
|
+
});
|
|
6998
|
+
propagateToContinuation(buffer, cell, x, y, { dim: true });
|
|
6999
|
+
return true;
|
|
7000
|
+
}
|
|
4754
7001
|
buffer.setCell(x, y, {
|
|
4755
7002
|
...cell,
|
|
4756
7003
|
fg: mixedRgb
|
|
@@ -4818,17 +7065,19 @@ init_src();
|
|
|
4818
7065
|
* Stage 2b — emit the Kitty graphics overlay for the plan.
|
|
4819
7066
|
*
|
|
4820
7067
|
* The output always begins with `CURSOR_SAVE + kittyDeleteAllScrimPlacements()
|
|
4821
|
-
* + CURSOR_RESTORE` when
|
|
7068
|
+
* + CURSOR_RESTORE` when the plan is realized — even when zero emoji cells
|
|
4822
7069
|
* fall inside the faded region this frame. The unconditional clear is what
|
|
4823
7070
|
* erases stale placements from a previous frame.
|
|
4824
7071
|
*
|
|
4825
|
-
* Returns `""` when
|
|
4826
|
-
*
|
|
4827
|
-
*
|
|
4828
|
-
*
|
|
7072
|
+
* Returns `""` when the plan can't be realized as a Kitty overlay. Public
|
|
7073
|
+
* API safety — callers (tests, future consumers) should be able to invoke
|
|
7074
|
+
* this directly without re-deriving the `kittyEnabled` / `scrim` /
|
|
7075
|
+
* `amount > 0` gate. The orchestrator (`./index.ts`) also gates with
|
|
7076
|
+
* `plan.kittyEnabled` for clarity, but the early-out below is the
|
|
7077
|
+
* authoritative contract.
|
|
4829
7078
|
*/
|
|
4830
7079
|
function realizeToKitty(plan, buffer) {
|
|
4831
|
-
if (!plan.active) return "";
|
|
7080
|
+
if (!plan.active || !plan.kittyEnabled || plan.amount <= 0 || plan.scrim === null) return "";
|
|
4832
7081
|
const cells = collectEmojiCellsInFadeRegion(buffer, plan);
|
|
4833
7082
|
const tint = hexToRgb$1(plan.scrim ?? plan.defaultBg ?? "#000000") ?? {
|
|
4834
7083
|
r: 0,
|
|
@@ -4866,13 +7115,13 @@ function realizeToKitty(plan, buffer) {
|
|
|
4866
7115
|
* overlay because their rendering ignores the fg color.
|
|
4867
7116
|
*
|
|
4868
7117
|
* The iteration order is deterministic (delegated to
|
|
4869
|
-
* `
|
|
7118
|
+
* `forEachBackdropCell`), matching the buffer realizer's order. STRICT
|
|
4870
7119
|
* mode compares the overlay string across fresh and incremental paths —
|
|
4871
7120
|
* any drift in this order would fail the comparison.
|
|
4872
7121
|
*/
|
|
4873
7122
|
function collectEmojiCellsInFadeRegion(buffer, plan) {
|
|
4874
7123
|
const out = [];
|
|
4875
|
-
|
|
7124
|
+
forEachBackdropCell(buffer.width, buffer.height, plan.includes, plan.excludes, (x, y) => {
|
|
4876
7125
|
if (x + 1 >= buffer.width) return;
|
|
4877
7126
|
if (!buffer.isCellWide(x, y)) return;
|
|
4878
7127
|
if (buffer.isCellContinuation(x, y)) return;
|
|
@@ -4917,7 +7166,7 @@ const EMPTY_RESULT = Object.freeze({
|
|
|
4917
7166
|
*/
|
|
4918
7167
|
function applyBackdrop(root, buffer, options) {
|
|
4919
7168
|
const plan = buildPlan(root, options);
|
|
4920
|
-
if (plan.mixedAmounts && process.env.NODE_ENV !== "production") console.warn(`[silvery:backdrop] multiple fade amounts in one frame
|
|
7169
|
+
if (plan.mixedAmounts && process.env.NODE_ENV !== "production") console.warn(`[silvery:backdrop] multiple fade amounts in one frame; plan.amount collapsed to the first observed (${plan.amount}). Visuals may differ from intent. See plan.ts / assertSingleAmount.`);
|
|
4921
7170
|
if (!plan.active) return EMPTY_RESULT;
|
|
4922
7171
|
return {
|
|
4923
7172
|
modified: realizeToBuffer(plan, buffer),
|
|
@@ -4925,62 +7174,6 @@ function applyBackdrop(root, buffer, options) {
|
|
|
4925
7174
|
};
|
|
4926
7175
|
}
|
|
4927
7176
|
//#endregion
|
|
4928
|
-
//#region \0@oxc-project+runtime@0.126.0/helpers/usingCtx.js
|
|
4929
|
-
function _usingCtx() {
|
|
4930
|
-
var r = "function" == typeof SuppressedError ? SuppressedError : function(r, e) {
|
|
4931
|
-
var n = Error();
|
|
4932
|
-
return n.name = "SuppressedError", n.error = r, n.suppressed = e, n;
|
|
4933
|
-
}, e = {}, n = [];
|
|
4934
|
-
function using(r, e) {
|
|
4935
|
-
if (null != e) {
|
|
4936
|
-
if (Object(e) !== e) throw new TypeError("using declarations can only be used with objects, functions, null, or undefined.");
|
|
4937
|
-
if (r) var o = e[Symbol.asyncDispose || Symbol["for"]("Symbol.asyncDispose")];
|
|
4938
|
-
if (void 0 === o && (o = e[Symbol.dispose || Symbol["for"]("Symbol.dispose")], r)) var t = o;
|
|
4939
|
-
if ("function" != typeof o) throw new TypeError("Object is not disposable.");
|
|
4940
|
-
t && (o = function o() {
|
|
4941
|
-
try {
|
|
4942
|
-
t.call(e);
|
|
4943
|
-
} catch (r) {
|
|
4944
|
-
return Promise.reject(r);
|
|
4945
|
-
}
|
|
4946
|
-
}), n.push({
|
|
4947
|
-
v: e,
|
|
4948
|
-
d: o,
|
|
4949
|
-
a: r
|
|
4950
|
-
});
|
|
4951
|
-
} else r && n.push({
|
|
4952
|
-
d: e,
|
|
4953
|
-
a: r
|
|
4954
|
-
});
|
|
4955
|
-
return e;
|
|
4956
|
-
}
|
|
4957
|
-
return {
|
|
4958
|
-
e,
|
|
4959
|
-
u: using.bind(null, !1),
|
|
4960
|
-
a: using.bind(null, !0),
|
|
4961
|
-
d: function d() {
|
|
4962
|
-
var o, t = this.e, s = 0;
|
|
4963
|
-
function next() {
|
|
4964
|
-
for (; o = n.pop();) try {
|
|
4965
|
-
if (!o.a && 1 === s) return s = 0, n.push(o), Promise.resolve().then(next);
|
|
4966
|
-
if (o.d) {
|
|
4967
|
-
var r = o.d.call(o.v);
|
|
4968
|
-
if (o.a) return s |= 2, Promise.resolve(r).then(next, err);
|
|
4969
|
-
} else s |= 1;
|
|
4970
|
-
} catch (r) {
|
|
4971
|
-
return err(r);
|
|
4972
|
-
}
|
|
4973
|
-
if (1 === s) return t !== e ? Promise.reject(t) : Promise.resolve();
|
|
4974
|
-
if (t !== e) throw t;
|
|
4975
|
-
}
|
|
4976
|
-
function err(n) {
|
|
4977
|
-
return t = t !== e ? new r(n, t) : n, next();
|
|
4978
|
-
}
|
|
4979
|
-
return next();
|
|
4980
|
-
}
|
|
4981
|
-
};
|
|
4982
|
-
}
|
|
4983
|
-
//#endregion
|
|
4984
7177
|
//#region packages/ag-term/src/ag.ts
|
|
4985
7178
|
/**
|
|
4986
7179
|
* Ag — tree + layout engine + renderer.
|
|
@@ -5071,129 +7264,106 @@ function createAg(root, options) {
|
|
|
5071
7264
|
const kittyGraphics = options?.kittyGraphics !== void 0 ? options.kittyGraphics : isKittyGraphicsEnabledFromEnv();
|
|
5072
7265
|
const ctx = measurer ? { measurer } : void 0;
|
|
5073
7266
|
let _prevBuffer = null;
|
|
7267
|
+
let _postState = createRenderPostState();
|
|
5074
7268
|
let _kittyActive = false;
|
|
5075
7269
|
let hasScroll = false;
|
|
5076
7270
|
let hasSticky = false;
|
|
5077
7271
|
function doLayout(cols, rows, opts) {
|
|
5078
|
-
|
|
5079
|
-
|
|
5080
|
-
|
|
5081
|
-
|
|
5082
|
-
log.debug?.("layout: skipped (Flexily clean, no scrollDirty, dimensions unchanged)");
|
|
5083
|
-
layoutPhase(root, cols, rows);
|
|
5084
|
-
return {
|
|
5085
|
-
tMeasure: 0,
|
|
5086
|
-
tLayout: 0,
|
|
5087
|
-
tScroll: 0,
|
|
5088
|
-
tScrollRect: 0,
|
|
5089
|
-
tNotify: 0
|
|
5090
|
-
};
|
|
5091
|
-
}
|
|
5092
|
-
const render = _usingCtx$1.u(baseLog.span("pipeline", {
|
|
5093
|
-
width: cols,
|
|
5094
|
-
height: rows
|
|
5095
|
-
}));
|
|
5096
|
-
let tMeasure;
|
|
5097
|
-
try {
|
|
5098
|
-
var _usingCtx3 = _usingCtx();
|
|
5099
|
-
_usingCtx3.u(render.span("measure"));
|
|
5100
|
-
const t = performance.now();
|
|
5101
|
-
measurePhase(root, ctx);
|
|
5102
|
-
tMeasure = performance.now() - t;
|
|
5103
|
-
log.debug?.(`measure: ${tMeasure.toFixed(2)}ms`);
|
|
5104
|
-
} catch (_) {
|
|
5105
|
-
_usingCtx3.e = _;
|
|
5106
|
-
} finally {
|
|
5107
|
-
_usingCtx3.d();
|
|
5108
|
-
}
|
|
5109
|
-
let tLayout;
|
|
5110
|
-
try {
|
|
5111
|
-
var _usingCtx4 = _usingCtx();
|
|
5112
|
-
_usingCtx4.u(render.span("layout"));
|
|
5113
|
-
const t = performance.now();
|
|
5114
|
-
layoutPhase(root, cols, rows);
|
|
5115
|
-
tLayout = performance.now() - t;
|
|
5116
|
-
log.debug?.(`layout: ${tLayout.toFixed(2)}ms`);
|
|
5117
|
-
} catch (_) {
|
|
5118
|
-
_usingCtx4.e = _;
|
|
5119
|
-
} finally {
|
|
5120
|
-
_usingCtx4.d();
|
|
5121
|
-
}
|
|
5122
|
-
strictLayoutOverflowCheck(root);
|
|
5123
|
-
if (!hasScroll || !hasSticky) {
|
|
5124
|
-
const features = detectPipelineFeatures(root);
|
|
5125
|
-
if (features.hasScroll) hasScroll = true;
|
|
5126
|
-
if (features.hasSticky) hasSticky = true;
|
|
5127
|
-
}
|
|
5128
|
-
let tScroll;
|
|
5129
|
-
if (hasScroll) try {
|
|
5130
|
-
var _usingCtx5 = _usingCtx();
|
|
5131
|
-
_usingCtx5.u(render.span("scroll"));
|
|
5132
|
-
const t = performance.now();
|
|
5133
|
-
scrollPhase(root, { skipStateUpdates: opts?.skipScrollStateUpdates });
|
|
5134
|
-
tScroll = performance.now() - t;
|
|
5135
|
-
} catch (_) {
|
|
5136
|
-
_usingCtx5.e = _;
|
|
5137
|
-
} finally {
|
|
5138
|
-
_usingCtx5.d();
|
|
5139
|
-
}
|
|
5140
|
-
else tScroll = 0;
|
|
5141
|
-
if (hasSticky) stickyPhase(root);
|
|
5142
|
-
let tScrollRect;
|
|
5143
|
-
try {
|
|
5144
|
-
var _usingCtx6 = _usingCtx();
|
|
5145
|
-
_usingCtx6.u(render.span("scrollRect"));
|
|
5146
|
-
const t = performance.now();
|
|
5147
|
-
if (hasScroll || hasSticky) scrollrectPhase(root);
|
|
5148
|
-
else scrollrectPhaseSimple(root);
|
|
5149
|
-
tScrollRect = performance.now() - t;
|
|
5150
|
-
} catch (_) {
|
|
5151
|
-
_usingCtx6.e = _;
|
|
5152
|
-
} finally {
|
|
5153
|
-
_usingCtx6.d();
|
|
5154
|
-
}
|
|
5155
|
-
let tNotify = 0;
|
|
5156
|
-
if (!opts?.skipLayoutNotifications) try {
|
|
5157
|
-
var _usingCtx7 = _usingCtx();
|
|
5158
|
-
_usingCtx7.u(render.span("notify"));
|
|
5159
|
-
const t = performance.now();
|
|
5160
|
-
notifyLayoutSubscribers(root);
|
|
5161
|
-
tNotify = performance.now() - t;
|
|
5162
|
-
} catch (_) {
|
|
5163
|
-
_usingCtx7.e = _;
|
|
5164
|
-
} finally {
|
|
5165
|
-
_usingCtx7.d();
|
|
5166
|
-
}
|
|
5167
|
-
const acc = globalThis.__silvery_bench_phases;
|
|
5168
|
-
if (acc) {
|
|
5169
|
-
acc.measure += tMeasure;
|
|
5170
|
-
acc.layout += tLayout;
|
|
5171
|
-
acc.scroll += tScroll;
|
|
5172
|
-
acc.scrollRect += tScrollRect;
|
|
5173
|
-
acc.notify += tNotify;
|
|
5174
|
-
acc.layoutTotal += tMeasure + tLayout + tScroll + tScrollRect + tNotify;
|
|
5175
|
-
}
|
|
7272
|
+
const prevRootLayout = root.boxRect;
|
|
7273
|
+
if (!(prevRootLayout && (prevRootLayout.width !== cols || prevRootLayout.height !== rows)) && !root.layoutNode?.isDirty() && !hasScrollDirty()) {
|
|
7274
|
+
log.debug?.("layout: skipped (Flexily clean, no scrollDirty, dimensions unchanged)");
|
|
7275
|
+
layoutPhase(root, cols, rows);
|
|
5176
7276
|
return {
|
|
5177
|
-
tMeasure,
|
|
5178
|
-
tLayout,
|
|
5179
|
-
tScroll,
|
|
5180
|
-
tScrollRect,
|
|
5181
|
-
tNotify
|
|
7277
|
+
tMeasure: 0,
|
|
7278
|
+
tLayout: 0,
|
|
7279
|
+
tScroll: 0,
|
|
7280
|
+
tScrollRect: 0,
|
|
7281
|
+
tNotify: 0
|
|
5182
7282
|
};
|
|
5183
|
-
} catch (_) {
|
|
5184
|
-
_usingCtx$1.e = _;
|
|
5185
|
-
} finally {
|
|
5186
|
-
_usingCtx$1.d();
|
|
5187
7283
|
}
|
|
7284
|
+
using render = baseLog.span?.("pipeline", {
|
|
7285
|
+
width: cols,
|
|
7286
|
+
height: rows
|
|
7287
|
+
});
|
|
7288
|
+
let tMeasure;
|
|
7289
|
+
{
|
|
7290
|
+
using _m = render?.span("measure");
|
|
7291
|
+
const t = performance.now();
|
|
7292
|
+
measurePhase(root, ctx);
|
|
7293
|
+
tMeasure = performance.now() - t;
|
|
7294
|
+
log.debug?.(`measure: ${tMeasure.toFixed(2)}ms`);
|
|
7295
|
+
}
|
|
7296
|
+
let tLayout;
|
|
7297
|
+
{
|
|
7298
|
+
using _l = render?.span("layout");
|
|
7299
|
+
const t = performance.now();
|
|
7300
|
+
layoutPhase(root, cols, rows);
|
|
7301
|
+
tLayout = performance.now() - t;
|
|
7302
|
+
log.debug?.(`layout: ${tLayout.toFixed(2)}ms`);
|
|
7303
|
+
}
|
|
7304
|
+
strictLayoutOverflowCheck(root);
|
|
7305
|
+
if (!hasScroll || !hasSticky) {
|
|
7306
|
+
const features = detectPipelineFeatures(root);
|
|
7307
|
+
if (features.hasScroll) hasScroll = true;
|
|
7308
|
+
if (features.hasSticky) hasSticky = true;
|
|
7309
|
+
}
|
|
7310
|
+
let tScroll;
|
|
7311
|
+
if (hasScroll) {
|
|
7312
|
+
using _s = render?.span("scroll");
|
|
7313
|
+
const t = performance.now();
|
|
7314
|
+
scrollPhase(root, { skipStateUpdates: opts?.skipScrollStateUpdates });
|
|
7315
|
+
tScroll = performance.now() - t;
|
|
7316
|
+
} else tScroll = 0;
|
|
7317
|
+
if (hasSticky) stickyPhase(root);
|
|
7318
|
+
let tScrollRect;
|
|
7319
|
+
{
|
|
7320
|
+
using _r = render?.span("scrollRect");
|
|
7321
|
+
const t = performance.now();
|
|
7322
|
+
if (hasScroll || hasSticky) scrollrectPhase(root);
|
|
7323
|
+
else scrollrectPhaseSimple(root);
|
|
7324
|
+
tScrollRect = performance.now() - t;
|
|
7325
|
+
}
|
|
7326
|
+
let tNotify = 0;
|
|
7327
|
+
if (!opts?.skipLayoutNotifications) {
|
|
7328
|
+
using _n = render?.span("notify");
|
|
7329
|
+
const t = performance.now();
|
|
7330
|
+
notifyLayoutSubscribers(root);
|
|
7331
|
+
tNotify = performance.now() - t;
|
|
7332
|
+
}
|
|
7333
|
+
const acc = globalThis.__silvery_bench_phases;
|
|
7334
|
+
if (acc) {
|
|
7335
|
+
acc.measure += tMeasure;
|
|
7336
|
+
acc.layout += tLayout;
|
|
7337
|
+
acc.scroll += tScroll;
|
|
7338
|
+
acc.scrollRect += tScrollRect;
|
|
7339
|
+
acc.notify += tNotify;
|
|
7340
|
+
acc.layoutTotal += tMeasure + tLayout + tScroll + tScrollRect + tNotify;
|
|
7341
|
+
}
|
|
7342
|
+
return {
|
|
7343
|
+
tMeasure,
|
|
7344
|
+
tLayout,
|
|
7345
|
+
tScroll,
|
|
7346
|
+
tScrollRect,
|
|
7347
|
+
tNotify
|
|
7348
|
+
};
|
|
5188
7349
|
}
|
|
5189
7350
|
function doRender(opts) {
|
|
5190
7351
|
clearBgConflictWarnings();
|
|
5191
7352
|
const prevBuffer = opts?.fresh ? null : opts?.prevBuffer !== void 0 ? opts.prevBuffer : _prevBuffer;
|
|
7353
|
+
const postState = opts?.fresh ? createRenderPostState() : opts?.postState ?? _postState;
|
|
5192
7354
|
let tContent;
|
|
5193
7355
|
let buffer;
|
|
5194
7356
|
{
|
|
5195
7357
|
const t = performance.now();
|
|
5196
|
-
|
|
7358
|
+
if (isRenderPlanEnabled()) {
|
|
7359
|
+
const layout = root.boxRect;
|
|
7360
|
+
if (!layout) throw new Error("doRender: SILVERY_RENDER_PLAN enabled but root has no boxRect");
|
|
7361
|
+
const captured = withPlanCapture(layout.width, layout.height, () => renderPhase(root, prevBuffer, ctx, postState));
|
|
7362
|
+
captured.result;
|
|
7363
|
+
const replay = prevBuffer && prevBuffer.width === layout.width && prevBuffer.height === layout.height ? prevBuffer.clone() : new TerminalBuffer(layout.width, layout.height);
|
|
7364
|
+
commitSectionedPlan(replay, captured.plan);
|
|
7365
|
+
buffer = replay;
|
|
7366
|
+
} else buffer = renderPhase(root, prevBuffer, ctx, postState);
|
|
5197
7367
|
tContent = performance.now() - t;
|
|
5198
7368
|
log.debug?.(`content: ${tContent.toFixed(2)}ms`);
|
|
5199
7369
|
}
|
|
@@ -5228,7 +7398,8 @@ function createAg(root, options) {
|
|
|
5228
7398
|
carryForwardBuffer,
|
|
5229
7399
|
prevBuffer,
|
|
5230
7400
|
tContent,
|
|
5231
|
-
overlay
|
|
7401
|
+
overlay,
|
|
7402
|
+
postState
|
|
5232
7403
|
};
|
|
5233
7404
|
}
|
|
5234
7405
|
function agCreateNode(type, props) {
|
|
@@ -5281,11 +7452,13 @@ function createAg(root, options) {
|
|
|
5281
7452
|
buffer: result.buffer,
|
|
5282
7453
|
carryForwardBuffer: result.carryForwardBuffer,
|
|
5283
7454
|
prevBuffer: result.prevBuffer,
|
|
5284
|
-
overlay: result.overlay
|
|
7455
|
+
overlay: result.overlay,
|
|
7456
|
+
postState: result.postState
|
|
5285
7457
|
};
|
|
5286
7458
|
},
|
|
5287
7459
|
resetBuffer() {
|
|
5288
7460
|
_prevBuffer = null;
|
|
7461
|
+
_postState = createRenderPostState();
|
|
5289
7462
|
},
|
|
5290
7463
|
createNode: agCreateNode,
|
|
5291
7464
|
insertChild: agInsertChild,
|
|
@@ -5362,7 +7535,7 @@ init_buffer();
|
|
|
5362
7535
|
let engineInitialized = false;
|
|
5363
7536
|
async function ensureLayoutEngine() {
|
|
5364
7537
|
if (engineInitialized || isLayoutEngineInitialized()) return;
|
|
5365
|
-
const { ensureDefaultLayoutEngine } = await import("./layout-engine-
|
|
7538
|
+
const { ensureDefaultLayoutEngine } = await import("./layout-engine-C07LEXWT.mjs");
|
|
5366
7539
|
await ensureDefaultLayoutEngine();
|
|
5367
7540
|
engineInitialized = true;
|
|
5368
7541
|
}
|
|
@@ -5426,7 +7599,7 @@ function renderStringSync(element, options = {}) {
|
|
|
5426
7599
|
removeListener: () => mockStdout,
|
|
5427
7600
|
addListener: () => mockStdout
|
|
5428
7601
|
};
|
|
5429
|
-
const mockTerm = createTerm({
|
|
7602
|
+
const mockTerm = createTerm({ colorLevel: plain ? null : "truecolor" });
|
|
5430
7603
|
const wrapped = React.createElement(TermContext.Provider, { value: mockTerm }, React.createElement(StdoutContext.Provider, { value: {
|
|
5431
7604
|
stdout: mockStdout,
|
|
5432
7605
|
write: () => {}
|
|
@@ -5509,6 +7682,6 @@ function withActEnvironment(fn) {
|
|
|
5509
7682
|
}
|
|
5510
7683
|
}
|
|
5511
7684
|
//#endregion
|
|
5512
|
-
export {
|
|
7685
|
+
export { printPassHistogram as _, createRenderPostState as a, setActiveColorLevel as c, appendHistogramJson as d, assertBoundedConvergence as f, notePassCommit as g, logPass as h, hexToRgb$1 as i, INSTRUMENT as l, beginPass as m, renderStringSync as n, isStrictEnabled as o, beginConvergenceLoop as p, createAg as r, parseColor as s, renderString as t, MAX_CONVERGENCE_PASSES as u, resetPassHistogram as v };
|
|
5513
7686
|
|
|
5514
|
-
//# sourceMappingURL=render-string-
|
|
7687
|
+
//# sourceMappingURL=render-string-DkQacASz.mjs.map
|