281 lines
10 KiB
JavaScript
281 lines
10 KiB
JavaScript
/* 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
|
||
};
|