10. 输入框可视文本:换行与滚动窗口
输入框只占终端底部很少几行,但 prompt 可能很长、可能有真实换行。promptTextView.ts 负责把完整文本按列宽换行、再裁出一个包含光标的可见窗口,并顺带算出输入框要占几行、怎么格式化校验错误。
两个入口
formatVisiblePrompt 是只取可见文本的简化版(光标固定在末尾),主要给测试和外部用:
export function formatVisiblePrompt(text, columns, maxVisibleLines): string {
return formatVisiblePromptView(text, columns, maxVisibleLines, text.length).text;
}
真正干活的是 formatVisiblePromptView,它同时返回可见文本和可见窗口内的光标索引:
export type VisiblePromptView = {
text: string;
cursorIndex: number;
};
export function formatVisiblePromptView(text, columns, maxVisibleLines, cursorIndex): VisiblePromptView {
const safeColumns = Math.max(1, columns);
const safeMaxVisibleLines = Math.max(1, maxVisibleLines);
const rows = wrapText(text, safeColumns);
const safeCursorIndex = Math.max(0, Math.min(cursorIndex, text.length));
const cursorRowIndex = resolveCursorRowIndex(rows, safeCursorIndex);
const lastVisibleStart = Math.max(0, rows.length - safeMaxVisibleLines);
// Keep the active cursor row visible by sliding the window upward only when
// the cursor would otherwise fall below the last visible composer row.
const visibleStart = Math.min(Math.max(0, cursorRowIndex - safeMaxVisibleLines + 1), lastVisibleStart);
const visibleRows = rows.slice(visibleStart, visibleStart + safeMaxVisibleLines);
const visibleCursorIndex = resolveVisibleCursorIndex(visibleRows, safeCursorIndex);
return {
text: visibleRows.map((row) => row.text).join('\n'),
cursorIndex: visibleCursorIndex
};
}
流程是:
- 用
wrapText把完整文本换行成若干行(每行带它在原文里的start/end偏移)。 - 用
resolveCursorRowIndex找出光标落在第几行。 - 算可见窗口的起点
visibleStart:让光标行尽量出现在窗口最后一行(cursorRowIndex - maxVisibleLines + 1),但不超过lastVisibleStart(已经到底就不再下滑)。这样打字到末尾时窗口跟着往下滚,光标始终可见,又不会滚过头露出空行。 - 切出可见行,再用
resolveVisibleCursorIndex把全局光标索引换算成可见文本里的索引。
返回的 text 是可见行用 \n 拼起来的字符串,cursorIndex 是它里面的光标位置——第 11 篇的光标定位就用这两个。
换行:wrapText
function wrapText(text: string, columns: number): WrappedPromptRow[] {
if (text.length === 0) {
return [{ text: '', start: 0, end: 0 }];
}
const rows: WrappedPromptRow[] = [];
let lineStart = 0;
while (lineStart <= text.length) {
const newlineIndex = text.indexOf('\n', lineStart);
const lineEnd = newlineIndex < 0 ? text.length : newlineIndex;
const rawLine = text.slice(lineStart, lineEnd);
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine;
if (line.length === 0) {
rows.push({ text: '', start: lineStart, end: lineStart });
} else {
for (let offset = 0; offset < line.length; offset += columns) {
const endOffset = Math.min(offset + columns, line.length);
rows.push({ text: line.slice(offset, endOffset), start: lineStart + offset, end: lineStart + endOffset });
}
}
if (newlineIndex < 0) {
break;
}
lineStart = newlineIndex + 1;
}
return rows;
}
和正文的换行不同,这里每一行都记录它对应原文的 start/end 偏移——因为光标定位需要从字符索引反查“在第几行第几列”。逻辑是:先按 \n 切硬行(顺手去掉 \r),每条硬行再按列宽切片。空行保留为一个 { text: '', start, end }。空文本特判返回单个空行,保证至少有一行能放光标。
光标行定位:resolveCursorRowIndex
function resolveCursorRowIndex(rows: WrappedPromptRow[], cursorIndex: number): number {
return Math.max(
0,
rows.findIndex((row) => cursorIndex >= row.start && cursorIndex <= row.end)
);
}
找第一个满足 start ≤ cursorIndex ≤ end 的行。区间两端都闭,所以光标停在某行末尾(等于 end)时仍归属该行。findIndex 找不到时返回 -1,用 Math.max(0, ...) 兜到第 0 行。
可见光标索引:resolveVisibleCursorIndex
function resolveVisibleCursorIndex(rows: WrappedPromptRow[], cursorIndex: number): number {
let visibleCursorIndex = 0;
for (const row of rows) {
if (cursorIndex >= row.start && cursorIndex <= row.end) {
return visibleCursorIndex + Math.min(row.text.length, cursorIndex - row.start);
}
visibleCursorIndex += row.text.length + 1;
}
return Math.max(0, visibleCursorIndex - 1);
}
把全局字符索引换算成“可见文本(用 \n 拼接)里的偏移”。它累加遍历可见行:每经过一行就加上 行长 + 1(那个 + 1 是拼接用的 \n)。命中光标所在行时,加上行内偏移 cursorIndex - row.start(再用 Math.min 夹住行长,处理光标落在行尾的情况)。光标在可见窗口之外时返回末尾兜底。
这个换算很关键:状态里的 cursorIndex 是相对完整文本的,但渲染和光标 API 面对的是可见文本,两者必须对齐。
输入框行数:countVisibleComposerRows
export function countVisibleComposerRows(visibleText, hasValidationError, hasBackgroundPadding): number {
return (
visibleText.split('\n').length +
(hasValidationError ? 1 : 0) +
(hasBackgroundPadding ? COMPOSER_BACKGROUND_PADDING_ROWS : 0)
);
}
输入框实际占用的行数 = 可见文本行数 + (有校验错误则 1 行)+ (有背景块则上下半行共 2 行,COMPOSER_BACKGROUND_PADDING_ROWS)。第 11 篇的组件把这个数通过 onVisibleRowsChange 回写到第 3 篇的 composerRowsAtom,布局据此给输入框留位——这就是“输入框长高 → 布局重算”那条回路的源头。
校验错误格式化:formatValidationError
export function formatValidationError(error: string, columns: number, shouldPad: boolean): string {
const errorLine = `ERROR: ${error}`;
return shouldPad ? errorLine.padEnd(columns, ' ') : errorLine;
}
给错误加 ERROR: 前缀(又是去色可读的语义标记),开了背景块时把整行 pad 满列宽,好让红字行的背景铺满。
下一篇收尾输入框:把可见文本、光标索引、行数交给 cursorPosition.ts 算光标坐标,交给 ComposerFrame.tsx 渲染,由 index.tsx 编排起来。