fix: improve custom input editor rendering

This commit is contained in:
Erik Kotsiuba
2026-04-19 16:32:27 +02:00
parent 8d8e7b67c9
commit bc92712053
4 changed files with 109 additions and 33 deletions
+35 -7
View File
@@ -63,7 +63,6 @@ export function renderEditorBlock(args: {
availableWidth,
placeholder,
placeholderColor = "muted",
contentColor = "text",
isEmpty = false,
} = args;
const innerLines =
@@ -87,18 +86,47 @@ export function renderEditorBlock(args: {
for (const editorLine of innerLines) {
lines.push(
truncateToWidth(
`${indent}${renderInputLine(
editorLine,
availableWidth,
theme,
contentColor
)}`,
`${indent}${renderEditorLine(editorLine, availableWidth, theme)}`,
width
)
);
}
}
function renderEditorLine(
line: string,
availableWidth: number,
theme: Theme
): string {
const innerWidth = Math.max(4, availableWidth - 2);
const truncated = truncateToWidth(line, innerWidth);
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
return renderPersistentBackground(
`${truncated}${padding}`,
theme,
"selectedBg"
);
}
function renderPersistentBackground(
text: string,
theme: Theme,
background: "selectedBg"
): string {
const marker = "__PI_BG_MARKER__";
const wrappedMarker = theme.bg(background, marker);
const markerIndex = wrappedMarker.indexOf(marker);
if (markerIndex === -1) {
return theme.bg(background, ` ${text} `);
}
const prefix = wrappedMarker.slice(0, markerIndex);
const suffix = wrappedMarker.slice(markerIndex + marker.length);
const ansiReset = "\u001b[0m";
const reopenedText = text.split(ansiReset).join(`${ansiReset}${prefix}`);
return `${prefix} ${reopenedText} ${suffix}`;
}
export function renderBox(
content: Array<{ text: string; color: ThemeColor }>,
width: number,
+14 -19
View File
@@ -15,7 +15,6 @@ import {
mergeColumns,
pushWrappedText,
renderEditorBlock,
renderInputLine,
renderPreviewPaneContent,
} from "./render-helpers.ts";
import type {
@@ -436,25 +435,21 @@ function renderInteractiveCustomOption(
optionColor: ReturnType<typeof getOptionColor>
) {
const { lines, editor, theme, width } = context;
const label = `${index + 1}. ${prefix}`;
const availableWidth = Math.max(
1,
width - visibleWidth(pointer) - visibleWidth(label)
);
const text = editor.getText().replace(/\n/g, " ");
const hasText = text.trim().length > 0;
const inputText = hasText ? text : UI_TEXT.editorPlaceholderInput;
const inputColor = hasText ? "text" : "muted";
lines.push(
truncateToWidth(
`${pointer}${theme.fg(optionColor, label)}${renderInputLine(
inputText,
availableWidth,
pushWrappedText(
lines,
`${index + 1}. ${prefix}Type your own`,
width,
theme,
inputColor
)}`,
width
)
optionColor,
pointer,
" ".repeat(visibleWidth(pointer))
);
renderIndentedEditor(
lines,
editor,
width,
theme,
UI_TEXT.editorPlaceholderInput
);
}
+21 -2
View File
@@ -5,6 +5,7 @@ import { renderEditorBlock } from "../src/ui/render-helpers.ts";
const ABC_PATTERN = /abc/;
const BORDER_PATTERN = /┌|└/;
const TYPE_YOUR_PATTERN = /Type your/;
const SELECTED_BG_PATTERN = /\{selectedBg\}/;
function mockTheme() {
return {
@@ -21,7 +22,7 @@ test("renderEditorBlock strips editor borders", () => {
const lines: string[] = [];
renderEditorBlock({
lines,
editorLines: ["┌──┐", "│abc│", "└──┘"],
editorLines: ["┌──┐", "abc", "└──┘"],
width: 40,
theme: mockTheme() as never,
indent: " ",
@@ -30,6 +31,7 @@ test("renderEditorBlock strips editor borders", () => {
assert.equal(lines.length, 1);
assert.match(lines[0], ABC_PATTERN);
assert.match(lines[0], SELECTED_BG_PATTERN);
assert.doesNotMatch(lines[0], BORDER_PATTERN);
});
@@ -37,7 +39,7 @@ test("renderEditorBlock shows a muted placeholder when empty", () => {
const lines: string[] = [];
renderEditorBlock({
lines,
editorLines: ["┌──┐", "│ │", "└──┘"],
editorLines: ["┌──┐", " ", "└──┘"],
width: 40,
theme: mockTheme() as never,
indent: " ",
@@ -48,5 +50,22 @@ test("renderEditorBlock shows a muted placeholder when empty", () => {
assert.equal(lines.length, 1);
assert.match(lines[0], TYPE_YOUR_PATTERN);
assert.match(lines[0], SELECTED_BG_PATTERN);
assert.doesNotMatch(lines[0], BORDER_PATTERN);
});
test("renderEditorBlock reapplies background after editor reset sequences", () => {
const lines: string[] = [];
renderEditorBlock({
lines,
editorLines: ["┌──┐", "abc\x1b[7m \x1b[0m", "└──┘"],
width: 40,
theme: mockTheme() as never,
indent: " ",
availableWidth: 40,
});
assert.equal(lines.length, 1);
assert.match(lines[0], SELECTED_BG_PATTERN);
assert(lines[0].includes("\x1b[0m{selectedBg}"));
});
+39 -5
View File
@@ -19,13 +19,13 @@ function mockTheme() {
} as never;
}
function mockEditor(text = "") {
function mockEditor(text = "", renderedLines?: string[]) {
return {
getText() {
return text;
},
render() {
return [];
return renderedLines ?? [];
},
} as never;
}
@@ -56,7 +56,7 @@ test("custom option stays labeled before selection", () => {
assert(!lines.some((line) => line.includes("Type your answer...")));
});
test("selected custom option becomes inline input", () => {
test("selected custom option keeps its label and renders editor below", () => {
let state = createInitialState({
questions: [
{
@@ -70,7 +70,7 @@ test("selected custom option becomes inline input", () => {
const lines: string[] = [];
renderQuestionScreen({
editor: mockEditor(""),
editor: mockEditor("", ["┌────┐", "", "└────┘"]),
lines,
options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
@@ -79,6 +79,40 @@ test("selected custom option becomes inline input", () => {
width: 80,
});
assert(lines.some((line) => line.includes("Type your own")));
assert(lines.some((line) => line.includes("Type your answer...")));
assert(!lines.some((line) => line.includes("Type your own answer")));
});
test("selected multiline custom option renders full editor block", () => {
let state = createInitialState({
questions: [
{
id: "q1",
prompt: "Pick one",
options: [{ value: "a", label: "A" }],
},
],
});
state = applyNumberShortcut(state, 2);
const lines: string[] = [];
renderQuestionScreen({
editor: mockEditor("first line\nsecond line", [
"┌────┐",
"first line",
"second line",
"└────┘",
]),
lines,
options: getRenderableOptions(state.questions[0]),
question: state.questions[0],
state,
theme: mockTheme(),
width: 80,
});
assert(lines.some((line) => line.includes("Type your own")));
assert(lines.some((line) => line.includes("first line")));
assert(lines.some((line) => line.includes("second line")));
assert(!lines.some((line) => line.includes("first line second line")));
});