diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index 00f0114..9c2a3e2 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -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, diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index 0d7a8db..5105146 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -15,7 +15,6 @@ import { mergeColumns, pushWrappedText, renderEditorBlock, - renderInputLine, renderPreviewPaneContent, } from "./render-helpers.ts"; import type { @@ -436,25 +435,21 @@ function renderInteractiveCustomOption( optionColor: ReturnType ) { const { lines, editor, theme, width } = context; - const label = `${index + 1}. ${prefix}`; - const availableWidth = Math.max( - 1, - width - visibleWidth(pointer) - visibleWidth(label) + pushWrappedText( + lines, + `${index + 1}. ${prefix}Type your own`, + width, + theme, + optionColor, + pointer, + " ".repeat(visibleWidth(pointer)) ); - 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, - theme, - inputColor - )}`, - width - ) + renderIndentedEditor( + lines, + editor, + width, + theme, + UI_TEXT.editorPlaceholderInput ); } diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts index 9a2cc3c..d644747 100644 --- a/tests/render-helpers.test.ts +++ b/tests/render-helpers.test.ts @@ -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}")); +}); diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index eb991c0..be9c89e 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -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"))); });