Files
remoteconn-gitea/apps/miniprogram/pages/terminal/terminalViewportModel.js
2026-03-21 18:57:10 +08:00

281 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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
};