From e0e2b046883b9d07745a6178c49cfef25e771424 Mon Sep 17 00:00:00 2001 From: Erik Kotsiuba Date: Sun, 19 Apr 2026 16:10:15 +0200 Subject: [PATCH] fix: polish ask note spacing and styling --- src/ui/constants.ts | 4 +- src/ui/render-helpers.ts | 54 ++++++++++++++++++++++- src/ui/render-question.ts | 80 +++++++++++++++++++++-------------- tests/render-question.test.ts | 40 +++++++++++++++++- 4 files changed, 143 insertions(+), 35 deletions(-) diff --git a/src/ui/constants.ts b/src/ui/constants.ts index ab12cc6..e567c63 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -12,7 +12,7 @@ export const UI_DIMENSIONS = { } as const; export const UI_TEXT = { - questionNoteTitle: "Note", + questionNoteTitle: "Note:", reviewTitle: "Review answers", readyToSubmit: "Submit answers?", unanswered: "→ unanswered", @@ -24,5 +24,5 @@ export const UI_TEXT = { footerDefault: " ⇆ tab · ↑↓ select · Enter confirm · N/Shift+N note · Esc dismiss", editorPlaceholderInput: "Type answer...", - editorPlaceholderNote: "Add note...", + editorPlaceholderNote: "Add a note...", } as const; diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index e377a99..cbc9ff6 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -5,7 +5,14 @@ import { wrapText } from "../text.ts"; import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts"; type Theme = ExtensionContext["ui"]["theme"]; -type ThemeColor = "accent" | "muted" | "text" | "dim" | "success" | "warning"; +type ThemeColor = + | "accent" + | "muted" + | "text" + | "dim" + | "success" + | "warning" + | "syntaxString"; const EDITOR_BORDER_PATTERN = /^[┌┐└┘─]+$/; const EDITOR_SCROLL_BORDER_PATTERN = /^─── [↑↓] \d+ more ─*$/; @@ -97,6 +104,51 @@ export function renderEditorBlock(args: { } } +export function renderLabeledEditorBlock(args: { + lines: string[]; + label: string; + editorLines: string[]; + width: number; + theme: Theme; + indent: string; + availableWidth: number; + placeholder?: string; + placeholderColor?: ThemeColor; + isEmpty?: boolean; +}) { + const { + lines, + label, + editorLines, + width, + theme, + indent, + availableWidth, + placeholder, + placeholderColor = "muted", + isEmpty = false, + } = args; + const contentLines = getEditorContentLines(editorLines); + const labelText = theme.fg("accent", label); + const contentIndent = `${indent}${" ".repeat(visibleWidth(label) + 1)}`; + const editorWidth = Math.max(4, availableWidth - visibleWidth(label) - 1); + const firstLine = + isEmpty && placeholder + ? renderInputLine(placeholder, editorWidth, theme, placeholderColor) + : renderEditorLine(contentLines[0] ?? "", editorWidth, theme); + + lines.push(truncateToWidth(`${indent}${labelText} ${firstLine}`, width)); + + for (const editorLine of contentLines.slice(1)) { + lines.push( + truncateToWidth( + `${contentIndent}${renderEditorLine(editorLine, availableWidth, theme)}`, + width + ) + ); + } +} + function getEditorContentLines(editorLines: string[]): string[] { if (editorLines.length <= 2) { return editorLines; diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index 5105146..661418b 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -26,8 +26,10 @@ import type { export function renderQuestionScreen(context: QuestionRenderContext) { const { lines, question, theme, width } = context; pushWrappedText(lines, question.prompt, width, theme, "text", " ", " "); - lines.push(""); - renderQuestionNote(context); + const renderedQuestionNote = renderQuestionNote(context); + if (!renderedQuestionNote) { + lines.push(""); + } if (question.type === "preview") { renderPreviewQuestion(context); @@ -37,51 +39,35 @@ export function renderQuestionScreen(context: QuestionRenderContext) { renderStandardQuestion(context); } -function renderQuestionNote(context: QuestionRenderContext) { +function renderQuestionNote(context: QuestionRenderContext): boolean { const { lines, state, question, theme, width, editor } = context; if (isQuestionNoteOpen(state, question.id)) { - pushWrappedText( + renderQuestionNoteEditor( lines, - UI_TEXT.questionNoteTitle, + editor, width, theme, - "accent", - " ", - " " + UI_TEXT.editorPlaceholderNote ); - renderEditorBlock({ - lines, - editorLines: editor.render( - Math.max( - UI_DIMENSIONS.editorMinWidth, - width - UI_DIMENSIONS.editorContentPadding - ) - ), - width, - theme, - indent: " ", - availableWidth: width - 3, - placeholder: UI_TEXT.editorPlaceholderNote, - isEmpty: editor.getText().length === 0, - }); lines.push(""); - return; + return true; } const existingNote = getQuestionNote(state, question.id); if (!existingNote) { - return; + return false; } pushWrappedText( lines, - `Question note: ${existingNote}`, + existingNote, width, theme, "muted", - " ", - " " + getSavedNotePrefix(theme, " "), + getSavedNoteContinuationPrefix(" ") ); lines.push(""); + return true; } function renderStandardQuestion(context: QuestionRenderContext) { @@ -383,15 +369,39 @@ function renderSavedOptionNote( const { lines, theme, width } = context; pushWrappedText( lines, - `Note: ${optionNote}`, + optionNote, width, theme, "muted", - " ", - " " + getSavedNotePrefix(theme, " "), + getSavedNoteContinuationPrefix(" ") ); } +function renderQuestionNoteEditor( + lines: string[], + editor: QuestionRenderContext["editor"], + width: number, + theme: Theme, + placeholder: string +) { + renderEditorBlock({ + lines, + editorLines: editor.render( + Math.max( + UI_DIMENSIONS.editorMinWidth, + width - UI_DIMENSIONS.editorContentPadding + ) + ), + width, + theme, + indent: " ", + availableWidth: width - 1, + placeholder, + isEmpty: editor.getText().length === 0, + }); +} + function renderIndentedEditor( lines: string[], editor: QuestionRenderContext["editor"], @@ -416,6 +426,14 @@ function renderIndentedEditor( }); } +function getSavedNotePrefix(theme: Theme, indent: string): string { + return `${indent}${theme.fg("syntaxString", UI_TEXT.questionNoteTitle)} `; +} + +function getSavedNoteContinuationPrefix(indent: string): string { + return `${indent}${" ".repeat(visibleWidth(UI_TEXT.questionNoteTitle) + 1)}`; +} + function getOptionPrefix( questionType: QuestionRenderContext["question"]["type"], option: AskDisplayOption, diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts index 3a21fe1..0c0a07a 100644 --- a/tests/render-question.test.ts +++ b/tests/render-question.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createInitialState } from "../src/state/create.ts"; import { getRenderableOptions } from "../src/state/selectors.ts"; -import { applyNumberShortcut } from "../src/state/transitions.ts"; +import { + applyNumberShortcut, + enterQuestionNoteMode, +} from "../src/state/transitions.ts"; import { renderQuestionScreen } from "../src/ui/render-question.ts"; function mockTheme() { @@ -83,6 +86,41 @@ test("selected custom option keeps its label and renders editor below", () => { assert(lines.some((line) => line.includes("Type answer..."))); }); +test("open question note renders inline label and editor", () => { + const state = enterQuestionNoteMode( + createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick any extra things to include.", + options: [{ value: "a", label: "A" }], + }, + ], + }), + "q1" + ); + + const lines: string[] = []; + renderQuestionScreen({ + editor: mockEditor("", ["┌────┐", "", "└────┘"]), + lines, + options: getRenderableOptions(state.questions[0]), + question: state.questions[0], + state, + theme: mockTheme(), + width: 80, + }); + + const promptIndex = lines.findIndex((line) => + line.includes("Pick any extra things to include.") + ); + const inputIndex = lines.findIndex((line) => line.includes("Add a note...")); + + assert.notEqual(promptIndex, -1); + assert.equal(inputIndex, promptIndex + 1); + assert(!lines.some((line) => line.includes("Note:"))); +}); + test("selected multiline custom option renders full editor block", () => { let state = createInitialState({ questions: [