feat(ask): refine inline answer and navigation UX
This commit is contained in:
@@ -114,9 +114,9 @@ export function enterOptionNoteMode(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toggleCurrentMultiOption(state: AskState): AskState {
|
export function toggleCurrentOption(state: AskState): AskState {
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
if (!question || question.type === "single") {
|
if (!question) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,9 +128,28 @@ export function toggleCurrentMultiOption(state: AskState): AskState {
|
|||||||
return setView(state, inputView(question.id));
|
return setView(state, inputView(question.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
return updateAnswer(state, question.id, (answer) =>
|
if (question.type === "multi") {
|
||||||
toggleSelection(answer, option, state.activeOptionIndex),
|
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 {
|
export function confirmCurrentSelection(state: AskState): AskState {
|
||||||
|
|||||||
+2
-2
@@ -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",
|
" ⇆ tab · ↑↓ select · Space toggle · Enter continue · N option note · Ctrl+N question note · Esc dismiss · 1-9 quick toggle",
|
||||||
footerDefault:
|
footerDefault:
|
||||||
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
|
" ⇆ tab · ↑↓ select · Enter confirm · N option note · Ctrl+N question note · Esc dismiss",
|
||||||
hintSave: "{newLineHint} newline · Enter save · Esc save and close",
|
editorPlaceholderInput: "Type your answer...",
|
||||||
hintSubmit: "{newLineHint} newline · Enter submit · Esc save and close",
|
editorPlaceholderNote: "Add a note...",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
+21
-10
@@ -25,7 +25,7 @@ import {
|
|||||||
} from "../state.ts";
|
} from "../state.ts";
|
||||||
import type { AskParams, AskResult, AskState } from "../types.ts";
|
import type { AskParams, AskResult, AskState } from "../types.ts";
|
||||||
import type { AskInputCommand } from "./input.ts";
|
import type { AskInputCommand } from "./input.ts";
|
||||||
import { formatKeybindingLabel, getInputCommand } from "./input.ts";
|
import { getInputCommand } from "./input.ts";
|
||||||
import { renderAskScreen } from "./render.ts";
|
import { renderAskScreen } from "./render.ts";
|
||||||
|
|
||||||
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
|
type CustomCallback = Parameters<ExtensionContext["ui"]["custom"]>[0];
|
||||||
@@ -41,7 +41,6 @@ interface AskFlowController {
|
|||||||
state: AskState;
|
state: AskState;
|
||||||
cachedLines: string[] | undefined;
|
cachedLines: string[] | undefined;
|
||||||
suppressAutoInputForSelection: boolean;
|
suppressAutoInputForSelection: boolean;
|
||||||
newLineHint: string;
|
|
||||||
tui: Tui;
|
tui: Tui;
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
@@ -58,7 +57,7 @@ export async function runAskFlow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createAskFlowController(
|
function createAskFlowController(
|
||||||
[tui, theme, keybindings, done]: [
|
[tui, theme, _keybindings, done]: [
|
||||||
Tui,
|
Tui,
|
||||||
Theme,
|
Theme,
|
||||||
Keybindings,
|
Keybindings,
|
||||||
@@ -70,9 +69,6 @@ function createAskFlowController(
|
|||||||
state: createInitialState(params),
|
state: createInitialState(params),
|
||||||
cachedLines: undefined,
|
cachedLines: undefined,
|
||||||
suppressAutoInputForSelection: false,
|
suppressAutoInputForSelection: false,
|
||||||
newLineHint: formatKeybindingLabel(
|
|
||||||
keybindings.getKeys("tui.input.newLine")[0] ?? "shift+enter",
|
|
||||||
),
|
|
||||||
tui,
|
tui,
|
||||||
theme,
|
theme,
|
||||||
editor: createEditor(tui, theme),
|
editor: createEditor(tui, theme),
|
||||||
@@ -103,7 +99,6 @@ function renderController(
|
|||||||
theme: controller.theme,
|
theme: controller.theme,
|
||||||
width,
|
width,
|
||||||
editor: controller.editor,
|
editor: controller.editor,
|
||||||
newLineHint: controller.newLineHint,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return controller.cachedLines;
|
return controller.cachedLines;
|
||||||
@@ -129,6 +124,12 @@ function handleEditingCommand(
|
|||||||
refresh(controller);
|
refresh(controller);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (command.kind === "editMoveOption") {
|
||||||
|
controller.state = saveEditorAndMoveOption(controller, command.delta);
|
||||||
|
syncInputModeWithSelection(controller);
|
||||||
|
refresh(controller);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (command.kind === "editClose") {
|
if (command.kind === "editClose") {
|
||||||
controller.state = saveEditorWithoutAdvancing(controller);
|
controller.state = saveEditorWithoutAdvancing(controller);
|
||||||
controller.suppressAutoInputForSelection =
|
controller.suppressAutoInputForSelection =
|
||||||
@@ -154,7 +155,7 @@ function handleNavigationCommand(
|
|||||||
navigateOptions(controller, command.delta);
|
navigateOptions(controller, command.delta);
|
||||||
return;
|
return;
|
||||||
case "toggleMulti":
|
case "toggleMulti":
|
||||||
handleToggleMulti(controller);
|
handleToggleCurrentOption(controller);
|
||||||
return;
|
return;
|
||||||
case "openQuestionNote":
|
case "openQuestionNote":
|
||||||
openQuestionNote(controller);
|
openQuestionNote(controller);
|
||||||
@@ -178,6 +179,7 @@ function handleNavigationCommand(
|
|||||||
return;
|
return;
|
||||||
case "ignore":
|
case "ignore":
|
||||||
case "editMoveTab":
|
case "editMoveTab":
|
||||||
|
case "editMoveOption":
|
||||||
case "editClose":
|
case "editClose":
|
||||||
case "delegateToEditor":
|
case "delegateToEditor":
|
||||||
return;
|
return;
|
||||||
@@ -200,9 +202,9 @@ function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
|
|||||||
refresh(controller);
|
refresh(controller);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleToggleMulti(controller: AskFlowController) {
|
function handleToggleCurrentOption(controller: AskFlowController) {
|
||||||
const question = getCurrentQuestion(controller.state);
|
const question = getCurrentQuestion(controller.state);
|
||||||
if (question?.type !== "multi") {
|
if (!question) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
controller.suppressAutoInputForSelection = false;
|
controller.suppressAutoInputForSelection = false;
|
||||||
@@ -319,6 +321,15 @@ function saveEditorAndMoveTab(
|
|||||||
return moveTab(nextState, delta);
|
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) {
|
function createEditor(tui: Tui, theme: Theme) {
|
||||||
const editorTheme: EditorTheme = {
|
const editorTheme: EditorTheme = {
|
||||||
borderColor: (text) => theme.fg("accent", text),
|
borderColor: (text) => theme.fg("accent", text),
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type AskInputCommand =
|
|||||||
| { kind: "cancel" }
|
| { kind: "cancel" }
|
||||||
| { kind: "numberShortcut"; digit: number }
|
| { kind: "numberShortcut"; digit: number }
|
||||||
| { kind: "editMoveTab"; delta: 1 | -1 }
|
| { kind: "editMoveTab"; delta: 1 | -1 }
|
||||||
|
| { kind: "editMoveOption"; delta: 1 | -1 }
|
||||||
| { kind: "editClose" }
|
| { kind: "editClose" }
|
||||||
| { kind: "delegateToEditor" }
|
| { kind: "delegateToEditor" }
|
||||||
| { kind: "ignore" };
|
| { kind: "ignore" };
|
||||||
@@ -45,6 +46,12 @@ function getEditingInputCommand(data: string): AskInputCommand {
|
|||||||
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
||||||
return { kind: "editMoveTab", delta: -1 };
|
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)) {
|
if (matchesKey(data, Key.escape)) {
|
||||||
return { kind: "editClose" };
|
return { kind: "editClose" };
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-11
@@ -34,11 +34,12 @@ export function renderInputLine(
|
|||||||
line: string,
|
line: string,
|
||||||
availableWidth: number,
|
availableWidth: number,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
|
color: ThemeColor = "text",
|
||||||
): string {
|
): string {
|
||||||
const innerWidth = Math.max(4, availableWidth - 2);
|
const innerWidth = Math.max(4, availableWidth - 2);
|
||||||
const truncated = truncateToWidth(line, innerWidth);
|
const truncated = truncateToWidth(line, innerWidth);
|
||||||
const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(truncated)));
|
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: {
|
export function renderEditorBlock(args: {
|
||||||
@@ -48,23 +49,53 @@ export function renderEditorBlock(args: {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
indent: string;
|
indent: string;
|
||||||
availableWidth: number;
|
availableWidth: number;
|
||||||
hint: string;
|
placeholder?: string;
|
||||||
|
placeholderColor?: ThemeColor;
|
||||||
|
contentColor?: ThemeColor;
|
||||||
|
isEmpty?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { lines, editorLines, width, theme, indent, availableWidth, hint } =
|
const {
|
||||||
args;
|
lines,
|
||||||
for (const editorLine of editorLines) {
|
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(
|
lines.push(
|
||||||
truncateToWidth(
|
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,
|
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(
|
export function renderBox(
|
||||||
|
|||||||
+44
-20
@@ -15,7 +15,7 @@ import {
|
|||||||
mergeColumns,
|
mergeColumns,
|
||||||
pushWrappedText,
|
pushWrappedText,
|
||||||
renderEditorBlock,
|
renderEditorBlock,
|
||||||
renderHint,
|
renderInputLine,
|
||||||
renderPreviewPaneContent,
|
renderPreviewPaneContent,
|
||||||
} from "./render-helpers.ts";
|
} from "./render-helpers.ts";
|
||||||
import type {
|
import type {
|
||||||
@@ -39,7 +39,7 @@ export function renderQuestionScreen(context: QuestionRenderContext) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderQuestionNote(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)) {
|
if (isQuestionNoteOpen(state, question.id)) {
|
||||||
pushWrappedText(
|
pushWrappedText(
|
||||||
lines,
|
lines,
|
||||||
@@ -62,7 +62,8 @@ function renderQuestionNote(context: QuestionRenderContext) {
|
|||||||
theme,
|
theme,
|
||||||
indent: " ",
|
indent: " ",
|
||||||
availableWidth: width - 3,
|
availableWidth: width - 3,
|
||||||
hint: renderHint(UI_TEXT.hintSave, newLineHint),
|
placeholder: UI_TEXT.editorPlaceholderNote,
|
||||||
|
isEmpty: editor.getText().length === 0,
|
||||||
});
|
});
|
||||||
lines.push("");
|
lines.push("");
|
||||||
return;
|
return;
|
||||||
@@ -105,6 +106,10 @@ function renderStandardOption(
|
|||||||
const pointer = selected ? "❯ " : " ";
|
const pointer = selected ? "❯ " : " ";
|
||||||
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
|
const prefix = getOptionPrefix(question.type, option, isAnsweredOption);
|
||||||
const optionColor = getOptionColor(isAnsweredOption, selected);
|
const optionColor = getOptionColor(isAnsweredOption, selected);
|
||||||
|
if (option.isCustomOption && selected && isInputOpenForQuestion(state, question.id)) {
|
||||||
|
renderInteractiveCustomOption(context, index, pointer, prefix, optionColor);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
pushWrappedText(
|
pushWrappedText(
|
||||||
lines,
|
lines,
|
||||||
@@ -321,25 +326,13 @@ function renderOptionEditor(
|
|||||||
context: OptionDetailRenderContext,
|
context: OptionDetailRenderContext,
|
||||||
renderState: ReturnType<typeof getOptionDetailRenderState>,
|
renderState: ReturnType<typeof getOptionDetailRenderState>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const { lines, editor, width, theme, newLineHint } = context;
|
const { lines, editor, width, theme } = context;
|
||||||
if (renderState.noteOpen) {
|
if (renderState.noteOpen) {
|
||||||
renderIndentedEditor(
|
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderNote);
|
||||||
lines,
|
|
||||||
editor,
|
|
||||||
width,
|
|
||||||
theme,
|
|
||||||
renderHint(UI_TEXT.hintSave, newLineHint),
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (renderState.inputOpen) {
|
if (renderState.inputOpen) {
|
||||||
renderIndentedEditor(
|
renderIndentedEditor(lines, editor, width, theme, UI_TEXT.editorPlaceholderInput);
|
||||||
lines,
|
|
||||||
editor,
|
|
||||||
width,
|
|
||||||
theme,
|
|
||||||
renderHint(UI_TEXT.hintSubmit, newLineHint),
|
|
||||||
);
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
@@ -389,7 +382,7 @@ function renderIndentedEditor(
|
|||||||
editor: QuestionRenderContext["editor"],
|
editor: QuestionRenderContext["editor"],
|
||||||
width: number,
|
width: number,
|
||||||
theme: Theme,
|
theme: Theme,
|
||||||
hint: string,
|
placeholder: string,
|
||||||
) {
|
) {
|
||||||
renderEditorBlock({
|
renderEditorBlock({
|
||||||
lines,
|
lines,
|
||||||
@@ -403,7 +396,8 @@ function renderIndentedEditor(
|
|||||||
theme,
|
theme,
|
||||||
indent: " ",
|
indent: " ",
|
||||||
availableWidth: width - 5,
|
availableWidth: width - 5,
|
||||||
hint,
|
placeholder,
|
||||||
|
isEmpty: editor.getText().length === 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -418,6 +412,36 @@ function getOptionPrefix(
|
|||||||
return `[${isAnsweredOption ? "x" : " "}] `;
|
return `[${isAnsweredOption ? "x" : " "}] `;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderInteractiveCustomOption(
|
||||||
|
context: QuestionRenderContext,
|
||||||
|
index: number,
|
||||||
|
pointer: string,
|
||||||
|
prefix: string,
|
||||||
|
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,
|
||||||
|
theme,
|
||||||
|
inputColor,
|
||||||
|
)}`,
|
||||||
|
width,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
|
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
|
||||||
if (isAnsweredOption) {
|
if (isAnsweredOption) {
|
||||||
return "success";
|
return "success";
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export interface QuestionRenderContext {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
width: number;
|
width: number;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
newLineHint: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OptionDetailRenderContext {
|
export interface OptionDetailRenderContext {
|
||||||
@@ -29,7 +28,6 @@ export interface OptionDetailRenderContext {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
width: number;
|
width: number;
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
newLineHint: string;
|
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
withGap?: boolean;
|
withGap?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-3
@@ -14,9 +14,8 @@ export function renderAskScreen(args: {
|
|||||||
theme: Theme;
|
theme: Theme;
|
||||||
width: number;
|
width: number;
|
||||||
editor: QuestionRenderContext["editor"];
|
editor: QuestionRenderContext["editor"];
|
||||||
newLineHint: string;
|
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const { state, theme, width, editor, newLineHint } = args;
|
const { state, theme, width, editor } = args;
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const question = getCurrentQuestion(state);
|
const question = getCurrentQuestion(state);
|
||||||
const options = getRenderableOptions(question);
|
const options = getRenderableOptions(question);
|
||||||
@@ -34,7 +33,6 @@ export function renderAskScreen(args: {
|
|||||||
theme,
|
theme,
|
||||||
width,
|
width,
|
||||||
editor,
|
editor,
|
||||||
newLineHint,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}</${color}>`;
|
||||||
|
},
|
||||||
|
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], /┌|└/);
|
||||||
|
});
|
||||||
@@ -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}</${color}>`;
|
||||||
|
},
|
||||||
|
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")));
|
||||||
|
});
|
||||||
@@ -122,6 +122,20 @@ test("multi-select space toggles current option and enter advances to submit", (
|
|||||||
assert.equal(state.completed, false);
|
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", () => {
|
test("submit can complete even when some questions are unanswered", () => {
|
||||||
let state = createInitialState(sampleParams());
|
let state = createInitialState(sampleParams());
|
||||||
state = moveTab(state, -1);
|
state = moveTab(state, -1);
|
||||||
|
|||||||
Reference in New Issue
Block a user