diff --git a/src/state/transitions.ts b/src/state/transitions.ts index a5171c0..bef58b0 100644 --- a/src/state/transitions.ts +++ b/src/state/transitions.ts @@ -114,9 +114,9 @@ export function enterOptionNoteMode( }); } -export function toggleCurrentMultiOption(state: AskState): AskState { +export function toggleCurrentOption(state: AskState): AskState { const question = getCurrentQuestion(state); - if (!question || question.type === "single") { + if (!question) { return state; } @@ -128,9 +128,28 @@ export function toggleCurrentMultiOption(state: AskState): AskState { return setView(state, inputView(question.id)); } - return updateAnswer(state, question.id, (answer) => - toggleSelection(answer, option, state.activeOptionIndex), - ); + if (question.type === "multi") { + return updateAnswer(state, question.id, (answer) => + toggleSelection(answer, option, state.activeOptionIndex), + ); + } + + return updateAnswer(state, question.id, (answer) => { + const isSelected = answer.selected.some( + (selection) => selection.value === option.value, + ); + if (isSelected) { + return { + ...answer, + selected: [], + }; + } + return setSingleSelection(answer, option, state.activeOptionIndex); + }); +} + +export function toggleCurrentMultiOption(state: AskState): AskState { + return toggleCurrentOption(state); } export function confirmCurrentSelection(state: AskState): AskState { diff --git a/src/ui/constants.ts b/src/ui/constants.ts index 5d69596..54eac9a 100644 --- a/src/ui/constants.ts +++ b/src/ui/constants.ts @@ -23,6 +23,6 @@ export const UI_TEXT = { " ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle", footerDefault: " ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss", - hintSave: "{newLineHint} newline · Enter save · Esc save and close", - hintSubmit: "{newLineHint} newline · Enter submit · Esc save and close", + editorPlaceholderInput: "Type your answer...", + editorPlaceholderNote: "Add a note...", } as const; diff --git a/src/ui/controller.ts b/src/ui/controller.ts index ba2c03c..fa28162 100644 --- a/src/ui/controller.ts +++ b/src/ui/controller.ts @@ -25,7 +25,7 @@ import { } from "../state.ts"; import type { AskParams, AskResult, AskState } from "../types.ts"; import type { AskInputCommand } from "./input.ts"; -import { formatKeybindingLabel, getInputCommand } from "./input.ts"; +import { getInputCommand } from "./input.ts"; import { renderAskScreen } from "./render.ts"; type CustomCallback = Parameters[0]; @@ -41,7 +41,6 @@ interface AskFlowController { state: AskState; cachedLines: string[] | undefined; suppressAutoInputForSelection: boolean; - newLineHint: string; tui: Tui; theme: Theme; editor: Editor; @@ -58,7 +57,7 @@ export async function runAskFlow( } function createAskFlowController( - [tui, theme, keybindings, done]: [ + [tui, theme, _keybindings, done]: [ Tui, Theme, Keybindings, @@ -70,9 +69,6 @@ function createAskFlowController( state: createInitialState(params), cachedLines: undefined, suppressAutoInputForSelection: false, - newLineHint: formatKeybindingLabel( - keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter", - ), tui, theme, editor: createEditor(tui, theme), @@ -103,7 +99,6 @@ function renderController( theme: controller.theme, width, editor: controller.editor, - newLineHint: controller.newLineHint, }); } return controller.cachedLines; @@ -129,6 +124,12 @@ function handleEditingCommand( refresh(controller); return; } + if (command.kind === "editMoveOption") { + controller.state = saveEditorAndMoveOption(controller, command.delta); + syncInputModeWithSelection(controller); + refresh(controller); + return; + } if (command.kind === "editClose") { controller.state = saveEditorWithoutAdvancing(controller); controller.suppressAutoInputForSelection = @@ -154,7 +155,7 @@ function handleNavigationCommand( navigateOptions(controller, command.delta); return; case "toggleMulti": - handleToggleMulti(controller); + handleToggleCurrentOption(controller); return; case "openQuestionNote": openQuestionNote(controller); @@ -178,6 +179,7 @@ function handleNavigationCommand( return; case "ignore": case "editMoveTab": + case "editMoveOption": case "editClose": case "delegateToEditor": return; @@ -200,9 +202,9 @@ function navigateOptions(controller: AskFlowController, delta: 1 | -1) { refresh(controller); } -function handleToggleMulti(controller: AskFlowController) { +function handleToggleCurrentOption(controller: AskFlowController) { const question = getCurrentQuestion(controller.state); - if (question?.type !== "multi") { + if (!question) { return; } controller.suppressAutoInputForSelection = false; @@ -319,6 +321,15 @@ function saveEditorAndMoveTab( return moveTab(nextState, delta); } +function saveEditorAndMoveOption( + controller: AskFlowController, + delta: 1 | -1, +): AskState { + const nextState = saveEditorWithoutAdvancing(controller); + controller.suppressAutoInputForSelection = false; + return moveOption(nextState, delta); +} + function createEditor(tui: Tui, theme: Theme) { const editorTheme: EditorTheme = { borderColor: (text) => theme.fg("accent", text), diff --git a/src/ui/input.ts b/src/ui/input.ts index 812bc72..af89141 100644 --- a/src/ui/input.ts +++ b/src/ui/input.ts @@ -11,6 +11,7 @@ export type AskInputCommand = | { kind: "cancel" } | { kind: "numberShortcut"; digit: number } | { kind: "editMoveTab"; delta: 1 | -1 } + | { kind: "editMoveOption"; delta: 1 | -1 } | { kind: "editClose" } | { kind: "delegateToEditor" } | { kind: "ignore" }; @@ -45,6 +46,12 @@ function getEditingInputCommand(data: string): AskInputCommand { if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { return { kind: "editMoveTab", delta: -1 }; } + if (matchesKey(data, Key.up)) { + return { kind: "editMoveOption", delta: -1 }; + } + if (matchesKey(data, Key.down)) { + return { kind: "editMoveOption", delta: 1 }; + } if (matchesKey(data, Key.escape)) { return { kind: "editClose" }; } diff --git a/src/ui/render-helpers.ts b/src/ui/render-helpers.ts index e56b7d9..815bc68 100644 --- a/src/ui/render-helpers.ts +++ b/src/ui/render-helpers.ts @@ -34,11 +34,12 @@ export function renderInputLine( line: string, availableWidth: number, theme: Theme, + color: ThemeColor = "text", ): string { const innerWidth = Math.max(4, availableWidth - 2); const truncated = truncateToWidth(line, innerWidth); const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated))); - return theme.bg("toolPendingBg", ` ${truncated}${padding} `); + return theme.bg("selectedBg", ` ${theme.fg(color, truncated)}${padding} `); } export function renderEditorBlock(args: { @@ -48,23 +49,53 @@ export function renderEditorBlock(args: { theme: Theme; indent: string; availableWidth: number; - hint: string; + placeholder?: string; + placeholderColor?: ThemeColor; + contentColor?: ThemeColor; + isEmpty?: boolean; }) { - const { lines, editorLines, width, theme, indent, availableWidth, hint } = - args; - for (const editorLine of editorLines) { + const { + lines, + editorLines, + width, + theme, + indent, + availableWidth, + placeholder, + placeholderColor = "muted", + contentColor = "text", + isEmpty = false, + } = args; + const innerLines = editorLines.length >= 2 ? editorLines.slice(1, -1) : editorLines; + + if (isEmpty && placeholder) { lines.push( truncateToWidth( - `${indent}${renderInputLine(editorLine, availableWidth, theme)}`, + `${indent}${renderInputLine( + placeholder, + availableWidth, + theme, + placeholderColor, + )}`, + width, + ), + ); + return; + } + + for (const editorLine of innerLines) { + lines.push( + truncateToWidth( + `${indent}${renderInputLine( + editorLine, + availableWidth, + theme, + contentColor, + )}`, width, ), ); } - lines.push(truncateToWidth(`${indent}${theme.fg("dim", hint)}`, width)); -} - -export function renderHint(template: string, newLineHint: string): string { - return template.replace("{newLineHint}", newLineHint); } export function renderBox( diff --git a/src/ui/render-question.ts b/src/ui/render-question.ts index db717f5..7655f64 100644 --- a/src/ui/render-question.ts +++ b/src/ui/render-question.ts @@ -15,7 +15,7 @@ import { mergeColumns, pushWrappedText, renderEditorBlock, - renderHint, + renderInputLine, renderPreviewPaneContent, } from "./render-helpers.ts"; import type { @@ -39,7 +39,7 @@ export function renderQuestionScreen(context: QuestionRenderContext) { } function renderQuestionNote(context: QuestionRenderContext) { - const { lines, state, question, theme, width, editor, newLineHint } = context; + const { lines, state, question, theme, width, editor } = context; if (isQuestionNoteOpen(state, question.id)) { pushWrappedText( lines, @@ -62,7 +62,8 @@ function renderQuestionNote(context: QuestionRenderContext) { theme, indent: " ", availableWidth: width - 3, - hint: renderHint(UI_TEXT.hintSave, newLineHint), + placeholder: UI_TEXT.editorPlaceholderNote, + isEmpty: editor.getText().length === 0, }); lines.push(""); return; @@ -105,6 +106,10 @@ function renderStandardOption( const pointer = selected ? "❯ " : " "; const prefix = getOptionPrefix(question.type, option, isAnsweredOption); const optionColor = getOptionColor(isAnsweredOption, selected); + if (option.isCustomOption && selected && isInputOpenForQuestion(state, question.id)) { + renderInteractiveCustomOption(context, index, pointer, prefix, optionColor); + return; + } pushWrappedText( lines, @@ -321,25 +326,13 @@ function renderOptionEditor( context: OptionDetailRenderContext, renderState: ReturnType, ): boolean { - const { lines, editor, width, theme, newLineHint } = context; + const { lines, editor, width, theme } = context; if (renderState.noteOpen) { - renderIndentedEditor( - lines, - editor, - width, - theme, - renderHint(UI_TEXT.hintSave, newLineHint), - ); + renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote); return true; } if (renderState.inputOpen) { - renderIndentedEditor( - lines, - editor, - width, - theme, - renderHint(UI_TEXT.hintSubmit, newLineHint), - ); + renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput); return true; } return false; @@ -389,7 +382,7 @@ function renderIndentedEditor( editor: QuestionRenderContext["editor"], width: number, theme: Theme, - hint: string, + placeholder: string, ) { renderEditorBlock({ lines, @@ -403,7 +396,8 @@ function renderIndentedEditor( theme, indent: " ", availableWidth: width - 5, - hint, + placeholder, + isEmpty: editor.getText().length === 0, }); } @@ -418,6 +412,36 @@ function getOptionPrefix( return `[${isAnsweredOption ? "x" : " "}] `; } +function renderInteractiveCustomOption( + context: QuestionRenderContext, + index: number, + pointer: string, + prefix: string, + optionColor: ReturnType, +) { + 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, + theme, + inputColor, + )}`, + width, + ), + ); +} + function getOptionColor(isAnsweredOption: boolean, selected: boolean) { if (isAnsweredOption) { return "success"; diff --git a/src/ui/render-types.ts b/src/ui/render-types.ts index b513b38..a077ce7 100644 --- a/src/ui/render-types.ts +++ b/src/ui/render-types.ts @@ -17,7 +17,6 @@ export interface QuestionRenderContext { theme: Theme; width: number; editor: Editor; - newLineHint: string; } export interface OptionDetailRenderContext { @@ -29,7 +28,6 @@ export interface OptionDetailRenderContext { theme: Theme; width: number; editor: Editor; - newLineHint: string; selected?: boolean; withGap?: boolean; } diff --git a/src/ui/render.ts b/src/ui/render.ts index 2e986e3..de4c39e 100644 --- a/src/ui/render.ts +++ b/src/ui/render.ts @@ -14,9 +14,8 @@ export function renderAskScreen(args: { theme: Theme; width: number; editor: QuestionRenderContext["editor"]; - newLineHint: string; }): string[] { - const { state, theme, width, editor, newLineHint } = args; + const { state, theme, width, editor } = args; const lines: string[] = []; const question = getCurrentQuestion(state); const options = getRenderableOptions(question); @@ -34,7 +33,6 @@ export function renderAskScreen(args: { theme, width, editor, - newLineHint, }); } diff --git a/tests/input.test.ts b/tests/input.test.ts new file mode 100644 index 0000000..ac82ae0 --- /dev/null +++ b/tests/input.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyNumberShortcut, createInitialState, enterQuestionNoteMode } from "../src/state.ts"; +import { getInputCommand } from "../src/ui/input.ts"; + +function inputState() { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Question?", + options: [{ value: "a", label: "A" }], + }, + ], + }); + state = applyNumberShortcut(state, 2); + return state; +} + +test("editing views let up and down move navigation", () => { + const input = inputState(); + assert.deepEqual(getInputCommand(input, "\x1b[A"), { + kind: "editMoveOption", + delta: -1, + }); + assert.deepEqual(getInputCommand(input, "\x1b[B"), { + kind: "editMoveOption", + delta: 1, + }); +}); + +test("note editing views let up and down move navigation", () => { + const state = enterQuestionNoteMode(createInitialState({ + questions: [ + { + id: "q1", + prompt: "Question?", + options: [{ value: "a", label: "A" }], + }, + ], + }), "q1"); + + assert.deepEqual(getInputCommand(state, "\x1b[A"), { + kind: "editMoveOption", + delta: -1, + }); + assert.deepEqual(getInputCommand(state, "\x1b[B"), { + kind: "editMoveOption", + delta: 1, + }); +}); diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts new file mode 100644 index 0000000..ad7efbd --- /dev/null +++ b/tests/render-helpers.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderEditorBlock } from "../src/ui/render-helpers.ts"; + +function mockTheme() { + return { + fg(color: string, text: string) { + return `<${color}>${text}`; + }, + bg(color: string, text: string) { + return `{${color}}${text}{/${color}}`; + }, + }; +} + +test("renderEditorBlock strips editor borders", () => { + const lines: string[] = []; + renderEditorBlock({ + lines, + editorLines: ["┌──┐", "│abc│", "└──┘"], + width: 40, + theme: mockTheme() as never, + indent: " ", + availableWidth: 40, + }); + + assert.equal(lines.length, 1); + assert.match(lines[0], /abc/); + assert.doesNotMatch(lines[0], /┌|└/); +}); + +test("renderEditorBlock shows a muted placeholder when empty", () => { + const lines: string[] = []; + renderEditorBlock({ + lines, + editorLines: ["┌──┐", "│ │", "└──┘"], + width: 40, + theme: mockTheme() as never, + indent: " ", + availableWidth: 40, + placeholder: "Type your", + isEmpty: true, + }); + + assert.equal(lines.length, 1); + assert.match(lines[0], /Type your/); + assert.doesNotMatch(lines[0], /┌|└/); +}); diff --git a/tests/render-question.test.ts b/tests/render-question.test.ts new file mode 100644 index 0000000..fd38b57 --- /dev/null +++ b/tests/render-question.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { applyNumberShortcut, createInitialState } from "../src/state.ts"; +import { getRenderableOptions } from "../src/state/selectors.ts"; +import { renderQuestionScreen } from "../src/ui/render-question.ts"; + +function mockTheme() { + return { + fg(color: string, text: string) { + return `<${color}>${text}`; + }, + bg(color: string, text: string) { + return `{${color}}${text}{/${color}}`; + }, + bold(text: string) { + return text; + }, + } as never; +} + +function mockEditor(text = "") { + return { + getText() { + return text; + }, + render() { + return []; + }, + } as never; +} + +test("custom option stays labeled before selection", () => { + const state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick one", + options: [{ value: "a", label: "A" }], + }, + ], + }); + + const lines: string[] = []; + renderQuestionScreen({ + lines, + state, + question: state.questions[0], + options: getRenderableOptions(state.questions[0]), + theme: mockTheme(), + width: 80, + editor: mockEditor(), + }); + + assert(lines.some((line) => line.includes("Type your own"))); + assert(!lines.some((line) => line.includes("Type your answer..."))); +}); + +test("selected custom option becomes inline input", () => { + let state = createInitialState({ + questions: [ + { + id: "q1", + prompt: "Pick one", + options: [{ value: "a", label: "A" }], + }, + ], + }); + state = applyNumberShortcut(state, 2); + + const lines: string[] = []; + renderQuestionScreen({ + lines, + state, + question: state.questions[0], + options: getRenderableOptions(state.questions[0]), + theme: mockTheme(), + width: 80, + editor: mockEditor(""), + }); + + assert(lines.some((line) => line.includes("Type your answer..."))); + assert(!lines.some((line) => line.includes("Type your own answer"))); +}); diff --git a/tests/state.test.ts b/tests/state.test.ts index 183b965..b1ef156 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -122,6 +122,20 @@ test("multi-select space toggles current option and enter advances to submit", ( assert.equal(state.completed, false); }); +test("single-select space toggles the active option without advancing", () => { + let state = createInitialState(sampleParams()); + state = toggleCurrentMultiOption(state); + + assert.equal(state.answers.lang.selected[0].label, "Python"); + assert.equal(state.activeTabIndex, 0); + assert.equal(state.view.kind, "navigate"); + + state = toggleCurrentMultiOption(state); + assert.equal(state.answers.lang, undefined); + assert.equal(state.activeTabIndex, 0); + assert.equal(state.view.kind, "navigate"); +}); + test("submit can complete even when some questions are unanswered", () => { let state = createInitialState(sampleParams()); state = moveTab(state, -1);