first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

View File

@@ -0,0 +1,280 @@
/* global module */
/**
* 终端视口层只关心两件事:
* 1. 当前到底应该渲染多少行,避免 normal buffer 在 prompt 下方保留虚假空白尾部;
* 2. 当前内容理论上的最大滚动值,保证 scroll-view 与 overlay 使用同一套边界。
*
* 这里不改 VT buffer 本身,只做页面投影。
*/
const TERMINAL_VIEWPORT_TARGET_RENDER_ROWS = 160;
const TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS = 24;
const TERMINAL_VIEWPORT_SCROLL_TARGET_RENDER_ROWS = 224;
const TERMINAL_VIEWPORT_SCROLL_MIN_EDGE_BUFFER_ROWS = 40;
const TERMINAL_VIEWPORT_DIRECTIONAL_LEAD_RATIO = 0.7;
function normalizeNonNegativeInteger(value, fallback) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return fallback;
return Math.max(0, Math.round(parsed));
}
function normalizeRows(rows) {
return Array.isArray(rows) && rows.length > 0 ? rows : [[]];
}
function normalizeOptionalNonNegativeInteger(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return null;
return Math.max(0, Math.round(parsed));
}
function normalizeActiveBufferName(activeBufferName) {
return activeBufferName === "alt" ? "alt" : "normal";
}
function normalizeScrollDirection(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 0;
if (parsed > 0) return 1;
if (parsed < 0) return -1;
return 0;
}
function lineHasVisibleText(line) {
if (!Array.isArray(line) || line.length === 0) {
return false;
}
return line.some((cell) => {
if (!cell || cell.continuation) {
return false;
}
return String(cell.text || "").length > 0;
});
}
function resolveLastNonEmptyRow(rows) {
for (let index = rows.length - 1; index >= 0; index -= 1) {
if (lineHasVisibleText(rows[index])) {
return index;
}
}
return 0;
}
/**
* normal buffer 的 live tail 应该以当前 cursor 行为上界。
* 否则哪怕 prompt 已经到底scroll-view 仍会因为尾部空行继续给出可滚动空间。
*
* alternate screen 维持整屏语义,不做裁剪。
*/
function resolveTerminalRenderRows(bufferRows, cursorRow, activeBufferName) {
const rows = normalizeRows(bufferRows);
if (normalizeActiveBufferName(activeBufferName) === "alt") {
return rows;
}
const cursorExclusive = normalizeNonNegativeInteger(cursorRow, 0) + 1;
const contentExclusive = resolveLastNonEmptyRow(rows) + 1;
const tailExclusive = Math.max(1, Math.min(rows.length, Math.max(cursorExclusive, contentExclusive)));
return rows.slice(0, tailExclusive);
}
function resolveTerminalMaxScrollTop(renderRowCount, visibleRows, lineHeight) {
const rows = Math.max(1, normalizeNonNegativeInteger(renderRowCount, 1));
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
const px = Math.max(1, normalizeNonNegativeInteger(lineHeight, 1));
return Math.max(0, (rows - viewportRows) * px);
}
function resolveTerminalTargetRenderRows(contentRowCount, visibleRows, minEdgeBufferRows, targetRenderRows) {
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
const edgeBufferRows = Math.max(
0,
normalizeNonNegativeInteger(minEdgeBufferRows, TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS)
);
const targetRows = Math.max(
1,
normalizeNonNegativeInteger(targetRenderRows, TERMINAL_VIEWPORT_TARGET_RENDER_ROWS)
);
const minimumRows = viewportRows + edgeBufferRows * 2;
return Math.min(rows, Math.max(minimumRows, targetRows));
}
function resolveTerminalDirectionalBuffers(extraRows, direction, minEdgeBufferRows) {
const remainingRows = Math.max(0, normalizeNonNegativeInteger(extraRows, 0));
const normalizedDirection = normalizeScrollDirection(direction);
const edgeBufferRows = Math.max(
0,
normalizeNonNegativeInteger(minEdgeBufferRows, TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS)
);
if (normalizedDirection === 0) {
const backwardRows = Math.floor(remainingRows / 2);
return {
backwardRows,
forwardRows: remainingRows - backwardRows
};
}
const trailingRows = Math.min(
remainingRows,
Math.max(
edgeBufferRows,
Math.floor(remainingRows * (1 - TERMINAL_VIEWPORT_DIRECTIONAL_LEAD_RATIO))
)
);
const leadingRows = Math.max(0, remainingRows - trailingRows);
return normalizedDirection > 0
? {
backwardRows: trailingRows,
forwardRows: leadingRows
}
: {
backwardRows: leadingRows,
forwardRows: trailingRows
};
}
function fillTerminalRenderWindow(renderStartRow, renderEndRow, contentRowCount, targetRenderRows) {
let startRow = Math.max(0, normalizeNonNegativeInteger(renderStartRow, 0));
let endRow = Math.max(startRow, normalizeNonNegativeInteger(renderEndRow, startRow));
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
const targetRows = Math.max(1, normalizeNonNegativeInteger(targetRenderRows, 1));
let missingRows = Math.max(0, targetRows - (endRow - startRow));
if (missingRows <= 0) {
return {
renderStartRow: startRow,
renderEndRow: endRow
};
}
const extendBackward = Math.min(startRow, missingRows);
startRow -= extendBackward;
missingRows -= extendBackward;
if (missingRows > 0) {
const extendForward = Math.min(rows - endRow, missingRows);
endRow += extendForward;
missingRows -= extendForward;
}
if (missingRows > 0) {
const extendBackwardAgain = Math.min(startRow, missingRows);
startRow -= extendBackwardAgain;
}
return {
renderStartRow: startRow,
renderEndRow: endRow
};
}
/**
* 小程序 scroll-view 一旦挂上几百行富文本,`setData` 和布局都会明显变重。
* 这里基于“当前目标 scrollTop + 固定总预算”只截出一个窗口,靠上下 spacer 维持完整滚动高度。
* 预算固定后,再根据最近滚动方向把更多余量分配到前方,兼顾性能与快速滑动时的预取需求。
*/
function resolveTerminalRenderWindow(contentRowCount, visibleRows, lineHeight, options) {
const source = options && typeof options === "object" ? options : {};
const rows = Math.max(1, normalizeNonNegativeInteger(contentRowCount, 1));
const viewportRows = Math.max(1, normalizeNonNegativeInteger(visibleRows, 1));
const px = Math.max(1, normalizeNonNegativeInteger(lineHeight, 1));
const maxScrollTop = resolveTerminalMaxScrollTop(rows, viewportRows, px);
const normalizedScrollTop = normalizeOptionalNonNegativeInteger(source.scrollTop);
const followTail = source.followTail === true;
const scrollDirection = normalizeScrollDirection(source.scrollDirection);
const scrollViewport = source.scrollViewport === true;
const minEdgeBufferRows = scrollViewport
? TERMINAL_VIEWPORT_SCROLL_MIN_EDGE_BUFFER_ROWS
: TERMINAL_VIEWPORT_MIN_EDGE_BUFFER_ROWS;
const targetRenderRows = resolveTerminalTargetRenderRows(
rows,
viewportRows,
minEdgeBufferRows,
scrollViewport ? TERMINAL_VIEWPORT_SCROLL_TARGET_RENDER_ROWS : TERMINAL_VIEWPORT_TARGET_RENDER_ROWS
);
let clampedScrollTop = 0;
let windowed = false;
if (followTail) {
clampedScrollTop = maxScrollTop;
windowed = true;
} else if (normalizedScrollTop !== null) {
clampedScrollTop = Math.min(maxScrollTop, normalizedScrollTop);
windowed = true;
}
if (!windowed) {
return {
clampedScrollTop,
visibleStartRow: 0,
visibleEndRow: rows,
renderStartRow: 0,
renderEndRow: rows,
topSpacerHeight: 0,
bottomSpacerHeight: 0,
backwardBufferRows: 0,
forwardBufferRows: 0
};
}
const maxVisibleStart = Math.max(0, rows - viewportRows);
const visibleStartRow = Math.min(maxVisibleStart, Math.max(0, Math.floor(clampedScrollTop / px)));
const visibleEndRow = Math.min(rows, visibleStartRow + viewportRows);
const extraRows = Math.max(0, targetRenderRows - viewportRows);
const directionalBuffers = resolveTerminalDirectionalBuffers(extraRows, scrollDirection, minEdgeBufferRows);
const filledWindow = fillTerminalRenderWindow(
Math.max(0, visibleStartRow - directionalBuffers.backwardRows),
Math.min(rows, visibleEndRow + directionalBuffers.forwardRows),
rows,
targetRenderRows
);
const renderStartRow = filledWindow.renderStartRow;
const renderEndRow = filledWindow.renderEndRow;
return {
clampedScrollTop,
visibleStartRow,
visibleEndRow,
renderStartRow,
renderEndRow,
topSpacerHeight: renderStartRow * px,
bottomSpacerHeight: Math.max(0, rows - renderEndRow) * px,
backwardBufferRows: Math.max(0, visibleStartRow - renderStartRow),
forwardBufferRows: Math.max(0, renderEndRow - visibleEndRow)
};
}
function buildTerminalViewportState(options) {
const source = options && typeof options === "object" ? options : {};
const contentRows = resolveTerminalRenderRows(source.bufferRows, source.cursorRow, source.activeBufferName);
const contentRowCount = contentRows.length;
const windowState = resolveTerminalRenderWindow(
contentRowCount,
source.visibleRows,
source.lineHeight,
source
);
const renderRows = contentRows.slice(windowState.renderStartRow, windowState.renderEndRow);
const renderRowCount = renderRows.length;
const maxScrollTop = resolveTerminalMaxScrollTop(contentRowCount, source.visibleRows, source.lineHeight);
return {
activeBufferName: normalizeActiveBufferName(source.activeBufferName),
contentRows,
contentRowCount,
clampedScrollTop: Math.min(maxScrollTop, Math.max(0, windowState.clampedScrollTop)),
renderRows,
renderRowCount,
visibleStartRow: windowState.visibleStartRow,
visibleEndRow: windowState.visibleEndRow,
renderStartRow: windowState.renderStartRow,
renderEndRow: windowState.renderEndRow,
topSpacerHeight: windowState.topSpacerHeight,
bottomSpacerHeight: windowState.bottomSpacerHeight,
backwardBufferRows: windowState.backwardBufferRows,
forwardBufferRows: windowState.forwardBufferRows,
maxScrollTop
};
}
module.exports = {
buildTerminalViewportState,
normalizeActiveBufferName,
resolveTerminalMaxScrollTop,
resolveTerminalRenderRows
};