diff --git a/src/ask-component.ts b/src/ask-component.ts index 1002ef5..4920423 100644 --- a/src/ask-component.ts +++ b/src/ask-component.ts @@ -5,6 +5,7 @@ import { Key, matchesKey, truncateToWidth, + visibleWidth, } from "@mariozechner/pi-tui"; import { allRequiredAnswered, @@ -12,6 +13,8 @@ import { cancelFlow, confirmCurrentSelection, createInitialState, + enterInputMode, + exitInputMode, getAnswer, getCurrentQuestion, getRenderableOptions, @@ -29,9 +32,14 @@ export async function runAskFlow( ctx: ExtensionContext, params: AskParams, ): Promise { - return ctx.ui.custom((tui, theme, _keybindings, done) => { + return ctx.ui.custom((tui, theme, keybindings, done) => { let state: AskState = createInitialState(params); let cachedLines: string[] | undefined; + let inputEscapePending = false; + let suppressAutoInputForSelection = false; + const newLineHint = formatKeybindingLabel( + keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter", + ); const editorTheme: EditorTheme = { borderColor: (s) => theme.fg("accent", s), @@ -46,6 +54,8 @@ export async function runAskFlow( const editor = new Editor(tui, editorTheme); editor.onSubmit = (value) => { + inputEscapePending = false; + suppressAutoInputForSelection = false; state = submitCustomAnswer(state, value); editor.setText(""); refresh(); @@ -63,52 +73,131 @@ export async function runAskFlow( } } + function hydrateEditorForInputMode() { + if (state.mode !== "input" || !state.inputQuestionId) { + return; + } + const existingText = + getAnswer(state, state.inputQuestionId)?.customText ?? ""; + editor.setText(existingText); + } + + function syncInputModeWithSelection() { + if (isSubmitTab(state)) { + return; + } + + const question = getCurrentQuestion(state); + if (!question) { + return; + } + + const option = getRenderableOptions(question)[state.optionIndex]; + if ( + !option?.isOther || + suppressAutoInputForSelection || + state.mode === "input" + ) { + return; + } + + state = enterInputMode(state, question.id); + inputEscapePending = false; + hydrateEditorForInputMode(); + } + + syncInputModeWithSelection(); + function handleInput(data: string) { if (state.mode === "input") { + if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + state = exitInputMode(state); + suppressAutoInputForSelection = false; + inputEscapePending = false; + state = moveTab(state, 1); + syncInputModeWithSelection(); + refresh(); + return; + } + if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + state = exitInputMode(state); + suppressAutoInputForSelection = false; + inputEscapePending = false; + state = moveTab(state, -1); + syncInputModeWithSelection(); + refresh(); + return; + } if (matchesKey(data, Key.escape)) { - state = cancelFlow(state); - if (!state.completed) { + if (editor.getText().trim().length === 0 || inputEscapePending) { + state = exitInputMode(state); + suppressAutoInputForSelection = true; + inputEscapePending = false; refresh(); return; } - maybeFinish(); + inputEscapePending = true; + refresh(); return; } + inputEscapePending = false; editor.handleInput(data); refresh(); return; } if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = moveTab(state, 1); + syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = moveTab(state, -1); + syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.up)) { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = moveOption(state, -1); + syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.down)) { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = moveOption(state, 1); + syncInputModeWithSelection(); refresh(); return; } if (matchesKey(data, Key.space)) { const question = getCurrentQuestion(state); if (question?.type === "multi") { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = toggleCurrentMultiOption(state); + if (state.mode === "input") { + hydrateEditorForInputMode(); + } refresh(); return; } } if (matchesKey(data, Key.enter)) { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = confirmCurrentSelection(state); + if (state.mode === "input") { + hydrateEditorForInputMode(); + } refresh(); maybeFinish(); return; @@ -122,7 +211,12 @@ export async function runAskFlow( const digit = parseDigit(data); if (digit !== null) { + suppressAutoInputForSelection = false; + inputEscapePending = false; state = applyNumberShortcut(state, digit); + if (state.mode === "input") { + hydrateEditorForInputMode(); + } refresh(); return; } @@ -176,20 +270,22 @@ export async function runAskFlow( } add(); } - } - - if (state.mode === "input") { - add(); - add(` ${theme.fg("muted", "Your answer:")}`); - for (const editorLine of editor.render(Math.max(1, width - 2))) { - add(` ${editorLine}`); + if (option.isOther && selected && state.mode === "input") { + for (const editorLine of editor.render(Math.max(8, width - 7))) { + add(` ${renderInputLine(editorLine, width - 5, theme)}`); + } + add( + ` ${theme.fg("dim", inputEscapePending ? `${newLineHint} newline · Enter submit · Esc again to go back` : `${newLineHint} newline · Enter submit · Esc to go back if empty`)}`, + ); } - add(); } } - add(); - add(renderFooter(state, theme)); + const footer = renderFooter(state, theme); + if (footer) { + add(); + add(footer); + } add(theme.fg("accent", "─".repeat(Math.max(1, width)))); cachedLines = lines; @@ -276,7 +372,7 @@ function renderFooter( theme: ExtensionContext["ui"]["theme"], ): string { if (state.mode === "input") { - return theme.fg("dim", " Enter submit · Esc go back"); + return ""; } const question = getCurrentQuestion(state); @@ -298,3 +394,26 @@ function renderFooter( function parseDigit(data: string): number | null { return /^[1-9]$/.test(data) ? Number(data) : null; } + +function renderInputLine( + line: string, + availableWidth: number, + theme: ExtensionContext["ui"]["theme"], +): 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} `); +} + +function formatKeybindingLabel(key: string): string { + return key + .split("+") + .map((part) => { + if (part.length <= 1) { + return part.toUpperCase(); + } + return part.charAt(0).toUpperCase() + part.slice(1); + }) + .join("+"); +} diff --git a/src/state.ts b/src/state.ts index 0fee943..ca993f8 100644 --- a/src/state.ts +++ b/src/state.ts @@ -53,12 +53,14 @@ export function isSubmitTab(state: AskState): boolean { } export function getRenderableOptions(question?: AskQuestion): RenderOption[] { - if (!question) return []; + if (!question) { + return []; + } const options: RenderOption[] = [...question.options]; if (question.allowOther) { options.push({ value: OTHER_VALUE, - label: "Type something.", + label: "Type your own", isOther: true, }); } diff --git a/tests/state.test.ts b/tests/state.test.ts index 76091e9..a4e5913 100644 --- a/tests/state.test.ts +++ b/tests/state.test.ts @@ -58,7 +58,7 @@ test("normalize defaults via initial state", () => { assert.equal(state.questions[0].required, true); assert.equal( getRenderableOptions(state.questions[0]).at(-1)?.label, - "Type something.", + "Type your own", ); });