// src/question-view.ts
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@mariozechner/pi-tui";
import {
isHighSurrogate,
OTHER_LABEL,
type Question,
type QuestionState,
SPLIT_PANE_LEFT_MIN,
SPLIT_PANE_MIN_WIDTH,
SPLIT_PANE_RIGHT_MIN,
SPLIT_PANE_SEPARATOR,
SURROGATE_PAIR_LEN,
type ThemeLike,
} from "./types";
const SPLIT_PANE_LEFT_RATIO = 0.42;
const DESCRIPTION_INDENT_MULTI = 10;
const DESCRIPTION_INDENT_SINGLE = 8;
const PREVIEW_MIN_WIDTH = 10;
const QUESTION_TEXT_MARGIN = 2;
export interface DisplayOption {
label: string;
description?: string;
isOther?: boolean;
}
/** 渲染上下文——收拢 q/state/theme/width 四个总是成对出现的参数。
* draftText 从 state.draftText 取(不再重复传递)。 */
export interface RenderContext {
question: Question;
state: QuestionState;
theme: ThemeLike;
width: number;
/** 单问题模式(无 Tab 提示)。 */
isSingle: boolean;
}
/** Other 自由输入 / 已保存预览的软换行行数上限。超出则截断并加省略号。 */
const MAX_EDITOR_LINES = 5;
/**
* 渲染编辑器文本,光标位置用反色高亮(ANSI SGR 7/27),不占额外列。
* surrogate pair 安全:光标在高代理前时反色高亮整个 code point(2 个 code unit),
* 与光标移动/Backspace 的跳过逻辑对称,避免拆散 emoji 导致终端显示替换字符。
* 光标在文本末尾(超出范围)时反色高亮一个空格占位。
*/
function renderCursorText(text: string, cursorPos: number): string {
const before = text.slice(0, cursorPos);
// 光标在高代理前 → 反色高亮整个 surrogate pair
const charLen = isHighSurrogate(text, cursorPos) ? SURROGATE_PAIR_LEN : 1;
const charAtCursor = text.slice(cursorPos, cursorPos + charLen) || " ";
const after = text.slice(cursorPos + charLen);
return `${before}\x1b[7m${charAtCursor}\x1b[27m${after}`;
}
/**
* 把一段带样式的文本按 availWidth 软换行输出为多行,最多 maxLines 行。
* - 首行前缀 lead(如 "> [ ] "),后续行用等宽空格缩进到 input 起始列对齐。
* - 超过 maxLines:截断到 maxLines 行,并在最后一行末尾用 ellipsis 提示。
* 此时末行可能已不含末尾光标字符,用 ellipsis 占位表达「还有更多」。
*
* @param push 输出回调(通常为带 truncateToWidth 的 add,提供安全兜底)
* @param lead 首行前缀(含选中标记 / 勾选框)
* @param content 待换行展示的已样式化文本(可含 ANSI + 末尾反色光标),为空则只输出 lead
* @param availWidth 单行可用宽度
* @param maxLines 最多行数
*/
function addWrappedInput(
push: (s: string) => void,
lead: string,
content: string,
availWidth: number,
maxLines: number,
): void {
const avail = Math.max(1, availWidth);
const indent = " ".repeat(visibleWidth(lead));
if (content === "") {
push(lead);
return;
}
let wrapped = wrapTextWithAnsi(content, avail);
if (wrapped.length > maxLines) {
wrapped = wrapped.slice(0, maxLines);
// 最后一行去掉超出,用省略号占位(光标已被省略号取代)
const lastIdx = wrapped.length - 1;
wrapped[lastIdx] = truncateToWidth(wrapped[lastIdx]!, Math.max(1, avail - 1), "…");
}
for (let i = 0; i < wrapped.length; i++) {
const seg = wrapped[i]!;
push(i === 0 ? `${lead}${seg}` : `${indent}${seg}`);
}
}
/** 在选项数组末尾追加 Other 自由输入项。 */
export function allOptions(q: Question): DisplayOption[] {
return [...q.options, { label: OTHER_LABEL, isOther: true }];
}
/**
* 宽终端(≥SPLIT_PANE_MIN_WIDTH)计算左右分屏宽度。窄终端返回 null(单列模式)。
*/
export function getSplitPaneWidths(width: number): { left: number; right: number } | null {
if (width < SPLIT_PANE_MIN_WIDTH) return null;
const available = width - SPLIT_PANE_SEPARATOR.length;
if (available < SPLIT_PANE_LEFT_MIN + SPLIT_PANE_RIGHT_MIN) return null;
const preferredLeft = Math.floor(available * SPLIT_PANE_LEFT_RATIO);
const left = Math.max(
SPLIT_PANE_LEFT_MIN,
Math.min(preferredLeft, available - SPLIT_PANE_RIGHT_MIN),
);
const right = available - left;
if (right < SPLIT_PANE_RIGHT_MIN) return null;
return { left, right };
}
/** 编辑器底部操作提示:随实现能力更新(实现偏差 D-005 已支持光标移动)。
* 反色光标占位已暗示可输入,故突出新增的方向键/移动能力。 */
const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit · Esc back";
/** 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
* freeform 模式下,Other 行**原地**变 [ ] 反色光标(多选)/ 反色光标(单选),
* 不再依赖 buildEditorBlock 的下方独立编辑块。 */
function buildOptionLines(
ctx: RenderContext,
hideDescriptions: boolean,
): string[] {
const { question: q, state, theme: t, width } = ctx;
const opts = allOptions(q);
const lines: string[] = [];
const add = (s: string): void => {
lines.push(truncateToWidth(s, width));
};
for (let i = 0; i < opts.length; i++) {
const opt = opts[i]!;
// 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
const activeOptionCursor = (state.mode === "freeform" || state.mode === "comment") ? state.savedOptionsCursorIndex : state.cursorIndex;
const isSelected = i === activeOptionCursor;
const isOther = opt.isOther === true;
const prefix = isSelected ? t.fg("accent", ">") : " ";
if (isOther) {
// 标记位宽度必须与普通选项一致,否则编号列错位:
// 单选 check = 1 列,多选 box = 3 列。
// 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
// 两种情况下 Other 编号都与普通选项错位。
if (state.mode === "freeform") {
const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
const num = i + 1;
const lead = `${prefix} ${marker} `;
const avail = Math.max(1, width - visibleWidth(lead));
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
} else {
const hasFreeText = state.freeTextValue !== null;
const marker = q.multiSelect
? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
: (hasFreeText ? t.fg("success", "✓") : " ");
const labelColor = isSelected ? "accent" : "text";
const num = i + 1;
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
if (hasFreeText) {
// 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
// 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
const numStr = `${num}.`;
const lead = " ".repeat(
visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
);
const avail = Math.max(1, width - visibleWidth(lead));
const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
}
}
} else if (q.multiSelect) {
const checked = state.selectedIndices.has(i);
const box = checked ? t.fg("accent", "[✓]") : t.fg("dim", "[ ]");
const labelColor = isSelected ? "accent" : "text";
const num = i + 1;
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
if (opt.description && !hideDescriptions) {
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
for (const line of wrapped) add(` ${line}`);
}
} else {
const isConfirmed = state.selectedIndex === i;
const check = isConfirmed ? t.fg("success", "✓") : " ";
const labelColor = isSelected ? "accent" : "text";
const num = i + 1;
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
if (opt.description && !hideDescriptions) {
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
for (const line of wrapped) add(` ${line}`);
}
}
}
return lines;
}
/** 构建分屏右侧 Markdown 详情预览。 */
function buildPreviewLines(
ctx: RenderContext,
maxLines: number,
): string[] {
const { question: q, state, theme: t, width } = ctx;
const opts = allOptions(q);
const opt = opts[state.cursorIndex];
if (!opt) return [t.fg("dim", "—")];
let text = "";
if (opt.isOther) {
text = `${opt.label}: enter a custom answer not listed above.`;
} else {
text = opt.label;
if (opt.description?.trim()) text += `\n\n${opt.description}`;
}
const wrapped = wrapTextWithAnsi(t.fg("muted", text), Math.max(PREVIEW_MIN_WIDTH, width));
const lines = wrapped.slice(0, maxLines);
if (wrapped.length > maxLines) lines.push(t.fg("dim", "…"));
return lines;
}
/**
* freeform 模式:editor 已在 buildOptionLines 中原地渲染([ ] 反色光标 行),
* buildEditorBlock 在此模式下不重复输出,**仅留出与正常 help 行同位置的视觉空隙**。
* comment 模式:保留独立编辑块(与 normal help 行解耦:comment 行有更长的 prompt)。
*/
function buildEditorBlock(
ctx: RenderContext,
): string[] {
const { state, theme: t, width } = ctx;
if (state.mode === "freeform") {
return [""];
}
const lines: string[] = [];
const add = (s: string): void => {
lines.push(truncateToWidth(s, width));
};
add("");
const prompt = t.fg("muted", " Your comment (optional):");
add(prompt);
// 渲染当前编辑器文本,光标用反色高亮当前字符(surrogate pair 安全)
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
add(` ${t.fg("text", cursorText)}`);
add("");
add(t.fg("dim", EDITOR_HINT));
return lines;
}
/** 渲染分屏模式下的左右双列(选项列表 + 详情预览)。 */
function buildSplitPane(
ctx: RenderContext,
split: { left: number; right: number },
): string[] {
const { theme: t, width } = ctx;
const lines: string[] = [];
const add = (s: string): void => {
lines.push(truncateToWidth(s, width));
};
const leftCtx: RenderContext = { ...ctx, width: split.left };
const rightCtx: RenderContext = { ...ctx, width: split.right };
const leftLines = buildOptionLines(leftCtx, true);
const rightLines = buildPreviewLines(rightCtx, Math.max(leftLines.length, 8));
const rowCount = Math.max(leftLines.length, rightLines.length);
const sep = t.fg("dim", SPLIT_PANE_SEPARATOR);
for (let i = 0; i < rowCount; i++) {
const left = truncateToWidth(leftLines[i] ?? "", split.left, "", true);
const right = truncateToWidth(rightLines[i] ?? "", split.right);
add(`${left}${sep}${right}`);
}
return lines;
}
/**
* 渲染单个问题视图(spec FR-4)。
*/
export function renderQuestionView(ctx: RenderContext): string[] {
const { question: q, state, theme: t, width, isSingle } = ctx;
const lines: string[] = [];
const add = (s: string): void => {
lines.push(truncateToWidth(s, width));
};
const divider = (): void => add(t.fg("dim", "─".repeat(Math.max(0, width))));
// 问题文本(word-wrap)
const wrapped = wrapTextWithAnsi(t.fg("text", ` ${q.question}`), width - QUESTION_TEXT_MARGIN);
for (const line of wrapped) add(line);
// 上下文(如有)
if (q.context?.trim()) {
divider();
const ctxWrapped = wrapTextWithAnsi(t.fg("muted", q.context), width - QUESTION_TEXT_MARGIN);
for (const line of ctxWrapped) add(line);
}
// 分屏判断
const split = getSplitPaneWidths(width);
// 选项模式下:question/context 与 options 之间加分割线(三段式)
// 编辑器模式不加(编辑器块自带视觉边界)
if (state.mode !== "freeform" && state.mode !== "comment") {
divider();
}
// 编辑器/评论模式:选项列表 + 编辑器块(freeform 模式下编辑器块为空,由 buildOptionLines 原地渲染)。
// 编辑器模式一律用全 width 单列渲染——分屏左列仅约 42% 宽,Other 自由输入会被压窄换行,
// 且右侧详情预览在输入自定义内容时无意义。隐藏 descriptions 以避免行数爆炸。
if (state.mode === "freeform" || state.mode === "comment") {
add("");
for (const line of buildOptionLines(ctx, false)) add(line);
lines.push(...buildEditorBlock(ctx));
if (state.mode === "freeform") {
// freeform 模式 help 行:光标锁在 Other 上,正在输入
add(t.fg("dim", EDITOR_HINT));
}
return lines;
}
if (!split) {
// 单列模式
for (const line of buildOptionLines(ctx, false)) add(line);
} else {
// 分屏模式
lines.push(...buildSplitPane(ctx, split));
}
add("");
// 帮助行(上下文相关)
const opts = allOptions(q);
const onOther = state.cursorIndex === opts.length - 1;
const tabHint = isSingle ? "" : " · ←/→ switch tabs";
const actionHint = onOther
? "Enter open editor"
: q.multiSelect
? "Space toggle · Enter confirm"
: "Enter select";
add(t.fg("dim", ` ↑↓ navigate · ${actionHint}${tabHint} · Esc back`));
return lines;
}