refactor: split ask flow state and view models
This commit is contained in:
+64
-124
@@ -1,13 +1,16 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { Editor, type EditorTheme } from "@mariozechner/pi-tui";
|
||||
import { createInitialState } from "../state/create.ts";
|
||||
import {
|
||||
getEditorDraft,
|
||||
saveEditorDraft,
|
||||
submitEditorDraft,
|
||||
syncStateToSelection,
|
||||
} from "../state/editor.ts";
|
||||
import { toAskResult } from "../state/result.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getCurrentOption,
|
||||
getCurrentQuestion,
|
||||
getOptionNote,
|
||||
getQuestionNote,
|
||||
isSubmitTab,
|
||||
} from "../state/selectors.ts";
|
||||
import {
|
||||
@@ -15,14 +18,10 @@ import {
|
||||
cancelFlow,
|
||||
confirmCurrentSelection,
|
||||
dismissFlow,
|
||||
enterInputMode,
|
||||
enterOptionNoteMode,
|
||||
enterQuestionNoteMode,
|
||||
moveOption,
|
||||
moveTab,
|
||||
saveCustomAnswer,
|
||||
saveNote,
|
||||
submitCustomAnswer,
|
||||
toggleCurrentMultiOption,
|
||||
} from "../state/transitions.ts";
|
||||
import { isEditingView } from "../state/view.ts";
|
||||
@@ -79,7 +78,7 @@ function createAskFlowController(
|
||||
};
|
||||
|
||||
controller.editor.onSubmit = (value) => submitEditor(controller, value);
|
||||
syncInputModeWithSelection(controller);
|
||||
syncSelection(controller);
|
||||
|
||||
return {
|
||||
render: (width: number) => renderController(controller, width),
|
||||
@@ -123,27 +122,19 @@ function handleEditingCommand(
|
||||
data: string
|
||||
) {
|
||||
if (command.kind === "dismiss") {
|
||||
commitNavigation(controller, dismissFlow(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editMoveTab") {
|
||||
controller.state = saveEditorAndMoveTab(controller, command.delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
commitSavedEditorNavigation(controller, moveTab, command.delta);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editMoveOption") {
|
||||
controller.state = saveEditorAndMoveOption(controller, command.delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
commitSavedEditorNavigation(controller, moveOption, command.delta);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "editClose") {
|
||||
controller.state = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection =
|
||||
controller.state.view.kind !== "input";
|
||||
refresh(controller);
|
||||
closeEditor(controller);
|
||||
return;
|
||||
}
|
||||
if (command.kind === "delegateToEditor") {
|
||||
@@ -158,10 +149,10 @@ function handleNavigationCommand(
|
||||
) {
|
||||
switch (command.kind) {
|
||||
case "moveTab":
|
||||
navigateTabs(controller, command.delta);
|
||||
commitState(controller, moveTab(controller.state, command.delta));
|
||||
return;
|
||||
case "moveOption":
|
||||
navigateOptions(controller, command.delta);
|
||||
commitState(controller, moveOption(controller.state, command.delta));
|
||||
return;
|
||||
case "toggleMulti":
|
||||
handleToggleCurrentOption(controller);
|
||||
@@ -173,22 +164,21 @@ function handleNavigationCommand(
|
||||
openOptionNote(controller);
|
||||
return;
|
||||
case "confirm":
|
||||
commitNavigation(controller, confirmCurrentSelection(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, confirmCurrentSelection(controller.state), {
|
||||
finish: true,
|
||||
});
|
||||
return;
|
||||
case "cancel":
|
||||
commitNavigation(controller, cancelFlow(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, cancelFlow(controller.state), { finish: true });
|
||||
return;
|
||||
case "numberShortcut":
|
||||
commitNavigation(
|
||||
commitState(
|
||||
controller,
|
||||
applyNumberShortcut(controller.state, command.digit)
|
||||
);
|
||||
return;
|
||||
case "dismiss":
|
||||
commitNavigation(controller, dismissFlow(controller.state));
|
||||
maybeFinish(controller);
|
||||
commitState(controller, dismissFlow(controller.state), { finish: true });
|
||||
return;
|
||||
case "ignore":
|
||||
case "editMoveTab":
|
||||
@@ -201,27 +191,12 @@ function handleNavigationCommand(
|
||||
}
|
||||
}
|
||||
|
||||
function navigateTabs(controller: AskFlowController, delta: 1 | -1) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = moveTab(controller.state, delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function navigateOptions(controller: AskFlowController, delta: 1 | -1) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = moveOption(controller.state, delta);
|
||||
syncInputModeWithSelection(controller);
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function handleToggleCurrentOption(controller: AskFlowController) {
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
if (!question) {
|
||||
return;
|
||||
}
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
commitNavigation(controller, toggleCurrentMultiOption(controller.state));
|
||||
commitState(controller, toggleCurrentMultiOption(controller.state));
|
||||
}
|
||||
|
||||
function openQuestionNote(controller: AskFlowController) {
|
||||
@@ -229,9 +204,13 @@ function openQuestionNote(controller: AskFlowController) {
|
||||
if (!question || isSubmitTab(controller.state)) {
|
||||
return;
|
||||
}
|
||||
controller.state = enterQuestionNoteMode(controller.state, question.id);
|
||||
hydrateEditorForCurrentView(controller);
|
||||
refresh(controller);
|
||||
commitState(
|
||||
controller,
|
||||
enterQuestionNoteMode(controller.state, question.id),
|
||||
{
|
||||
syncSelection: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function openOptionNote(controller: AskFlowController) {
|
||||
@@ -244,38 +223,51 @@ function openOptionNote(controller: AskFlowController) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
controller.state = enterOptionNoteMode(
|
||||
controller.state,
|
||||
question.id,
|
||||
option.value
|
||||
commitState(
|
||||
controller,
|
||||
enterOptionNoteMode(controller.state, question.id, option.value),
|
||||
{ syncSelection: false }
|
||||
);
|
||||
hydrateEditorForCurrentView(controller);
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function commitNavigation(controller: AskFlowController, nextState: AskState) {
|
||||
function commitState(
|
||||
controller: AskFlowController,
|
||||
nextState: AskState,
|
||||
options: { finish?: boolean; syncSelection?: boolean } = {}
|
||||
) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = nextState;
|
||||
hydrateEditorForCurrentView(controller);
|
||||
if (options.syncSelection !== false) {
|
||||
syncSelection(controller);
|
||||
}
|
||||
hydrateEditor(controller);
|
||||
refresh(controller);
|
||||
if (options.finish) {
|
||||
maybeFinish(controller);
|
||||
}
|
||||
}
|
||||
|
||||
function submitEditor(controller: AskFlowController, value: string) {
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
controller.state = submitEditorValue(controller.state, value);
|
||||
controller.state = submitEditorDraft(controller.state, value);
|
||||
controller.editor.setText("");
|
||||
refresh(controller);
|
||||
maybeFinish(controller);
|
||||
}
|
||||
|
||||
function submitEditorValue(state: AskState, value: string): AskState {
|
||||
if (state.view.kind === "input") {
|
||||
return submitCustomAnswer(state, value);
|
||||
}
|
||||
if (state.view.kind === "note") {
|
||||
return saveNote(state, value);
|
||||
}
|
||||
return state;
|
||||
function commitSavedEditorNavigation(
|
||||
controller: AskFlowController,
|
||||
navigate: (state: AskState, delta: 1 | -1) => AskState,
|
||||
delta: 1 | -1
|
||||
) {
|
||||
commitState(controller, navigate(saveEditorState(controller), delta));
|
||||
}
|
||||
|
||||
function closeEditor(controller: AskFlowController) {
|
||||
const nextState = saveEditorState(controller);
|
||||
controller.suppressAutoInputForSelection = nextState.view.kind !== "input";
|
||||
controller.state = nextState;
|
||||
refresh(controller);
|
||||
}
|
||||
|
||||
function refresh(controller: AskFlowController) {
|
||||
@@ -288,57 +280,21 @@ function maybeFinish(controller: AskFlowController) {
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateEditorForCurrentView(controller: AskFlowController) {
|
||||
controller.editor.setText(getEditorTextForCurrentView(controller.state));
|
||||
function hydrateEditor(controller: AskFlowController) {
|
||||
controller.editor.setText(getEditorDraft(controller.state));
|
||||
}
|
||||
|
||||
function syncInputModeWithSelection(controller: AskFlowController) {
|
||||
if (
|
||||
isEditingView(controller.state) ||
|
||||
isSubmitTab(controller.state) ||
|
||||
controller.suppressAutoInputForSelection
|
||||
) {
|
||||
function syncSelection(controller: AskFlowController) {
|
||||
if (controller.suppressAutoInputForSelection) {
|
||||
return;
|
||||
}
|
||||
|
||||
const question = getCurrentQuestion(controller.state);
|
||||
const option = getCurrentOption(controller.state);
|
||||
if (!(question && option?.isCustomOption)) {
|
||||
return;
|
||||
}
|
||||
|
||||
controller.state = enterInputMode(controller.state, question.id);
|
||||
hydrateEditorForCurrentView(controller);
|
||||
controller.state = syncStateToSelection(controller.state);
|
||||
}
|
||||
|
||||
function saveEditorWithoutAdvancing(controller: AskFlowController): AskState {
|
||||
function saveEditorState(controller: AskFlowController): AskState {
|
||||
const text = controller.editor.getText();
|
||||
controller.editor.setText("");
|
||||
if (controller.state.view.kind === "input") {
|
||||
return saveCustomAnswer(controller.state, text);
|
||||
}
|
||||
if (controller.state.view.kind === "note") {
|
||||
return saveNote(controller.state, text);
|
||||
}
|
||||
return controller.state;
|
||||
}
|
||||
|
||||
function saveEditorAndMoveTab(
|
||||
controller: AskFlowController,
|
||||
delta: 1 | -1
|
||||
): AskState {
|
||||
const nextState = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
return moveTab(nextState, delta);
|
||||
}
|
||||
|
||||
function saveEditorAndMoveOption(
|
||||
controller: AskFlowController,
|
||||
delta: 1 | -1
|
||||
): AskState {
|
||||
const nextState = saveEditorWithoutAdvancing(controller);
|
||||
controller.suppressAutoInputForSelection = false;
|
||||
return moveOption(nextState, delta);
|
||||
return saveEditorDraft(controller.state, text);
|
||||
}
|
||||
|
||||
function createEditor(tui: Tui, theme: Theme, cwd: string) {
|
||||
@@ -359,19 +315,3 @@ function createEditorTheme(theme: Theme): EditorTheme {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorTextForCurrentView(state: AskState): string {
|
||||
if (state.view.kind === "input") {
|
||||
return getAnswer(state, state.view.questionId)?.customText ?? "";
|
||||
}
|
||||
if (state.view.kind === "note") {
|
||||
if (state.view.optionValue) {
|
||||
return (
|
||||
getOptionNote(state, state.view.questionId, state.view.optionValue) ??
|
||||
""
|
||||
);
|
||||
}
|
||||
return getQuestionNote(state, state.view.questionId) ?? "";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { NO_PREVIEW_TEXT } from "../constants.ts";
|
||||
import { clamp } from "../math.ts";
|
||||
import { wrapText } from "../text.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
|
||||
|
||||
@@ -343,6 +344,30 @@ export function getSavedNotePrefixes(
|
||||
};
|
||||
}
|
||||
|
||||
export function pushSavedNote(args: {
|
||||
lines: string[];
|
||||
note: string;
|
||||
width: number;
|
||||
theme: Theme;
|
||||
indent: string;
|
||||
label?: string;
|
||||
}) {
|
||||
const { lines, note, width, theme, indent, label } = args;
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent,
|
||||
label,
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
note,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
}
|
||||
|
||||
export function renderFooterText(
|
||||
mode: "input" | "note" | "submit" | "multi" | "default"
|
||||
) {
|
||||
@@ -363,7 +388,3 @@ export function renderFooterText(
|
||||
function padToVisibleWidth(text: string, width: number): string {
|
||||
return text + " ".repeat(Math.max(0, width - visibleWidth(text)));
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
+155
-357
@@ -1,189 +1,141 @@
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { isOptionSelected } from "../state/answers.ts";
|
||||
import { UI_DIMENSIONS } from "./constants.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getOptionNote,
|
||||
getQuestionNote,
|
||||
isInputOpenForQuestion,
|
||||
isOptionNoteOpen,
|
||||
isQuestionNoteOpen,
|
||||
} from "../state/selectors.ts";
|
||||
import type { AskDisplayOption } from "../types.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
|
||||
import {
|
||||
getSavedNotePrefixes,
|
||||
measurePreviewLeftWidth,
|
||||
mergeColumns,
|
||||
pushSavedNote,
|
||||
pushWrappedText,
|
||||
renderEditorBlock,
|
||||
renderPreviewPaneContent,
|
||||
} from "./render-helpers.ts";
|
||||
import type {
|
||||
OptionDetailRenderContext,
|
||||
QuestionRenderContext,
|
||||
Theme,
|
||||
} from "./render-types.ts";
|
||||
import type { QuestionRenderContext, Theme } from "./render-types.ts";
|
||||
import {
|
||||
buildQuestionScreenModel,
|
||||
type OptionDetailModel,
|
||||
type OptionRowModel,
|
||||
} from "./view-models/question.ts";
|
||||
|
||||
export function renderQuestionScreen(context: QuestionRenderContext) {
|
||||
const { lines, question, theme, width } = context;
|
||||
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
|
||||
const renderedQuestionNote = renderQuestionNote(context);
|
||||
if (!renderedQuestionNote) {
|
||||
lines.push("");
|
||||
}
|
||||
const model = buildQuestionScreenModel(context);
|
||||
|
||||
if (question.type === "preview") {
|
||||
renderPreviewQuestion(context);
|
||||
pushWrappedText(lines, question.prompt, width, theme, "text", " ", " ");
|
||||
renderQuestionNote(lines, model.questionNote, context);
|
||||
|
||||
if (model.mode === "preview") {
|
||||
renderPreviewQuestion(context, model);
|
||||
return;
|
||||
}
|
||||
|
||||
renderStandardQuestion(context);
|
||||
for (const row of model.rows) {
|
||||
renderStandardOption(lines, row, context);
|
||||
}
|
||||
}
|
||||
|
||||
function renderQuestionNote(context: QuestionRenderContext): boolean {
|
||||
const { lines, state, question, theme, width, editor } = context;
|
||||
if (isQuestionNoteOpen(state, question.id)) {
|
||||
renderQuestionNoteEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderNote
|
||||
);
|
||||
function renderQuestionNote(
|
||||
lines: string[],
|
||||
questionNote: ReturnType<typeof buildQuestionScreenModel>["questionNote"],
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
if (!questionNote) {
|
||||
lines.push("");
|
||||
return true;
|
||||
return;
|
||||
}
|
||||
|
||||
const existingNote = getQuestionNote(state, question.id);
|
||||
if (!existingNote) {
|
||||
return false;
|
||||
if (questionNote.kind === "editor") {
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorContentPadding,
|
||||
placeholder: questionNote.placeholder,
|
||||
});
|
||||
lines.push("");
|
||||
return;
|
||||
}
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: questionNote.text,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
existingNote,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
lines.push("");
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderStandardQuestion(context: QuestionRenderContext) {
|
||||
const { options } = context;
|
||||
for (let index = 0; index < options.length; index++) {
|
||||
renderStandardOption(context, options[index], index);
|
||||
}
|
||||
}
|
||||
|
||||
function renderStandardOption(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption,
|
||||
index: number
|
||||
lines: string[],
|
||||
row: OptionRowModel,
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
const { lines, state, question, theme, width } = context;
|
||||
const answer = getAnswer(state, question.id);
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const isAnsweredOption = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
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);
|
||||
if (row.isCustom && row.detail?.kind === "editor") {
|
||||
renderInteractiveCustomOption(lines, row, context);
|
||||
return;
|
||||
}
|
||||
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${prefix}${option.label}`,
|
||||
width,
|
||||
theme,
|
||||
optionColor,
|
||||
pointer,
|
||||
" ".repeat(visibleWidth(pointer))
|
||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
||||
context.width,
|
||||
context.theme,
|
||||
row.color,
|
||||
row.pointer,
|
||||
" ".repeat(visibleWidth(row.pointer))
|
||||
);
|
||||
renderOptionMeta({
|
||||
...context,
|
||||
questionId: question.id,
|
||||
answer,
|
||||
option,
|
||||
selected,
|
||||
renderOptionDescription(lines, row.description, context.width, context.theme);
|
||||
renderOptionDetail(lines, row.detail, context, {
|
||||
suppressLeadingGap: !!row.description,
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreviewQuestion(context: QuestionRenderContext) {
|
||||
const { lines, state, question, options, theme, width } = context;
|
||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||
const selectedOption = options[state.activeOptionIndex];
|
||||
const answer = getAnswer(state, question.id);
|
||||
function renderPreviewQuestion(
|
||||
context: QuestionRenderContext,
|
||||
model: ReturnType<typeof buildQuestionScreenModel>
|
||||
) {
|
||||
if (model.mode !== "preview") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedOption?.isCustomOption) {
|
||||
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
|
||||
} else if (width >= UI_DIMENSIONS.previewWideMinWidth && options.length > 0) {
|
||||
const { lines, width, theme } = context;
|
||||
const add = (text = "") => lines.push(truncateToWidth(text, width));
|
||||
|
||||
if (model.previewLayout === "custom") {
|
||||
renderPreviewOptionList(model.rows, theme, width).forEach(add);
|
||||
} else if (model.previewLayout === "wide") {
|
||||
renderWidePreviewLayout(
|
||||
add,
|
||||
state,
|
||||
options,
|
||||
model.rows,
|
||||
theme,
|
||||
width,
|
||||
answer,
|
||||
selectedOption
|
||||
model.selectedOption
|
||||
);
|
||||
} else {
|
||||
renderStackedPreviewLayout(
|
||||
add,
|
||||
state,
|
||||
options,
|
||||
model.rows,
|
||||
theme,
|
||||
width,
|
||||
answer,
|
||||
selectedOption
|
||||
model.selectedOption
|
||||
);
|
||||
}
|
||||
|
||||
renderOptionEditorOrNote({
|
||||
...context,
|
||||
questionId: question.id,
|
||||
answer,
|
||||
option: selectedOption,
|
||||
selected: true,
|
||||
// Custom preview options already render their inline editor/content above.
|
||||
withGap: !selectedOption?.isCustomOption,
|
||||
});
|
||||
renderOptionDetail(lines, model.selectedOptionDetail, context);
|
||||
}
|
||||
|
||||
function renderWidePreviewLayout(
|
||||
add: (text?: string) => void,
|
||||
state: QuestionRenderContext["state"],
|
||||
options: QuestionRenderContext["options"],
|
||||
rows: OptionRowModel[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
answer: ReturnType<typeof getAnswer>,
|
||||
selectedOption: AskDisplayOption | undefined
|
||||
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
|
||||
) {
|
||||
const leftWidth = measurePreviewLeftWidth(options, width);
|
||||
const leftWidth = measurePreviewLeftWidth(rows, width);
|
||||
const rightWidth = Math.max(
|
||||
UI_DIMENSIONS.previewMinRightWidth,
|
||||
width - leftWidth - 2
|
||||
);
|
||||
const leftPane = renderPreviewOptionList(
|
||||
state,
|
||||
options,
|
||||
theme,
|
||||
leftWidth,
|
||||
answer
|
||||
);
|
||||
const leftPane = renderPreviewOptionList(rows, theme, leftWidth);
|
||||
const rightPane = renderPreviewPaneContent(selectedOption, theme, rightWidth);
|
||||
for (const line of mergeColumns(leftPane, rightPane, leftWidth, width)) {
|
||||
add(line);
|
||||
@@ -192,294 +144,140 @@ function renderWidePreviewLayout(
|
||||
|
||||
function renderStackedPreviewLayout(
|
||||
add: (text?: string) => void,
|
||||
state: QuestionRenderContext["state"],
|
||||
options: QuestionRenderContext["options"],
|
||||
rows: OptionRowModel[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
answer: ReturnType<typeof getAnswer>,
|
||||
selectedOption: AskDisplayOption | undefined
|
||||
selectedOption: ReturnType<typeof buildQuestionScreenModel>["selectedOption"]
|
||||
) {
|
||||
renderPreviewOptionList(state, options, theme, width, answer).forEach(add);
|
||||
renderPreviewOptionList(rows, theme, width).forEach(add);
|
||||
add("");
|
||||
renderPreviewPaneContent(selectedOption, theme, width).forEach(add);
|
||||
}
|
||||
|
||||
function renderPreviewOptionList(
|
||||
state: QuestionRenderContext["state"],
|
||||
options: QuestionRenderContext["options"],
|
||||
rows: OptionRowModel[],
|
||||
theme: Theme,
|
||||
width: number,
|
||||
answer: ReturnType<typeof getAnswer>
|
||||
width: number
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < options.length; index++) {
|
||||
const option = options[index];
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const isAnsweredOption = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
for (const row of rows) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${option.label}`,
|
||||
`${row.index + 1}. ${row.label}`,
|
||||
width,
|
||||
theme,
|
||||
getOptionColor(isAnsweredOption, selected),
|
||||
selected ? "❯ " : " ",
|
||||
row.color,
|
||||
row.pointer,
|
||||
" "
|
||||
);
|
||||
if (option.description) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
option.description,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
}
|
||||
renderOptionDescription(lines, row.description, width, theme);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderOptionMeta(
|
||||
context: QuestionRenderContext & {
|
||||
questionId: string;
|
||||
answer: ReturnType<typeof getAnswer>;
|
||||
option: AskDisplayOption;
|
||||
selected: boolean;
|
||||
}
|
||||
function renderOptionDetail(
|
||||
lines: string[],
|
||||
detail: OptionDetailModel | undefined,
|
||||
context: QuestionRenderContext,
|
||||
options: { suppressLeadingGap?: boolean } = {}
|
||||
) {
|
||||
const { lines, option, theme, width } = context;
|
||||
if (option.description) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
option.description,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
}
|
||||
renderOptionEditorOrNote(context);
|
||||
}
|
||||
|
||||
function renderOptionEditorOrNote(context: OptionDetailRenderContext) {
|
||||
const { lines, option, withGap } = context;
|
||||
if (!option) {
|
||||
if (!detail) {
|
||||
return;
|
||||
}
|
||||
|
||||
const renderState = getOptionDetailRenderState(context);
|
||||
if (withGap && hasOptionDetailContent(renderState)) {
|
||||
if (detail.withGap && !options.suppressLeadingGap) {
|
||||
lines.push("");
|
||||
}
|
||||
if (renderOptionEditor(context, renderState)) {
|
||||
if (detail.kind === "editor") {
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
||||
placeholder: detail.placeholder,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (renderOptionContent(context, renderState)) {
|
||||
if (detail.kind === "saved-note") {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: detail.text,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
});
|
||||
return;
|
||||
}
|
||||
renderSavedOptionNote(context, renderState.optionNote);
|
||||
}
|
||||
|
||||
function getOptionDetailRenderState(context: OptionDetailRenderContext) {
|
||||
const { state, questionId, answer, option, selected } = context;
|
||||
const inputOpen =
|
||||
option?.isCustomOption &&
|
||||
selected &&
|
||||
isInputOpenForQuestion(state, questionId);
|
||||
const noteOpen =
|
||||
!!option &&
|
||||
!option.isCustomOption &&
|
||||
isOptionNoteOpen(state, questionId, option.value);
|
||||
const optionNote =
|
||||
option && !option.isCustomOption
|
||||
? getOptionNote(state, questionId, option.value)
|
||||
: undefined;
|
||||
const customText = option?.isCustomOption ? answer?.customText : undefined;
|
||||
|
||||
return {
|
||||
inputOpen,
|
||||
noteOpen,
|
||||
optionNote,
|
||||
customText,
|
||||
};
|
||||
}
|
||||
|
||||
function hasOptionDetailContent(
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>
|
||||
) {
|
||||
return !!(
|
||||
renderState.noteOpen ||
|
||||
renderState.inputOpen ||
|
||||
renderState.customText ||
|
||||
renderState.optionNote
|
||||
);
|
||||
}
|
||||
|
||||
function renderOptionEditor(
|
||||
context: OptionDetailRenderContext,
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>
|
||||
): boolean {
|
||||
const { lines, editor, width, theme } = context;
|
||||
if (renderState.noteOpen) {
|
||||
renderIndentedEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderNote
|
||||
);
|
||||
return true;
|
||||
}
|
||||
if (renderState.inputOpen) {
|
||||
renderIndentedEditor(
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderInput
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function renderOptionContent(
|
||||
context: OptionDetailRenderContext,
|
||||
renderState: ReturnType<typeof getOptionDetailRenderState>
|
||||
): boolean {
|
||||
const { lines, option, theme, width } = context;
|
||||
if (!(option?.isCustomOption && renderState.customText)) {
|
||||
return false;
|
||||
}
|
||||
pushWrappedText(
|
||||
lines,
|
||||
renderState.customText,
|
||||
width,
|
||||
theme,
|
||||
detail.text,
|
||||
context.width,
|
||||
context.theme,
|
||||
"muted",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderSavedOptionNote(
|
||||
context: OptionDetailRenderContext,
|
||||
optionNote: string | undefined
|
||||
) {
|
||||
if (!optionNote) {
|
||||
return;
|
||||
}
|
||||
const { lines, theme, width } = context;
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent: " ",
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
optionNote,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
}
|
||||
|
||||
function renderQuestionNoteEditor(
|
||||
lines: string[],
|
||||
editor: QuestionRenderContext["editor"],
|
||||
width: number,
|
||||
theme: Theme,
|
||||
placeholder: string
|
||||
) {
|
||||
function renderEditorWithIndent(args: {
|
||||
lines: string[];
|
||||
editor: QuestionRenderContext["editor"];
|
||||
width: number;
|
||||
theme: Theme;
|
||||
indent: string;
|
||||
padding: number;
|
||||
placeholder: string;
|
||||
}) {
|
||||
const { lines, editor, width, theme, indent, padding, placeholder } = args;
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
editorLines: editor.render(
|
||||
Math.max(
|
||||
UI_DIMENSIONS.editorMinWidth,
|
||||
width - UI_DIMENSIONS.editorContentPadding
|
||||
)
|
||||
Math.max(UI_DIMENSIONS.editorMinWidth, width - padding)
|
||||
),
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
availableWidth: width - 1,
|
||||
indent,
|
||||
availableWidth: width - visibleWidth(indent),
|
||||
placeholder,
|
||||
isEmpty: editor.getText().length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function renderIndentedEditor(
|
||||
lines: string[],
|
||||
editor: QuestionRenderContext["editor"],
|
||||
width: number,
|
||||
theme: Theme,
|
||||
placeholder: string
|
||||
) {
|
||||
renderEditorBlock({
|
||||
lines,
|
||||
editorLines: editor.render(
|
||||
Math.max(
|
||||
UI_DIMENSIONS.editorMinWidth,
|
||||
width - UI_DIMENSIONS.editorIndentedPadding
|
||||
)
|
||||
),
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
availableWidth: width - 5,
|
||||
placeholder,
|
||||
isEmpty: editor.getText().length === 0,
|
||||
});
|
||||
}
|
||||
|
||||
function getOptionPrefix(
|
||||
questionType: QuestionRenderContext["question"]["type"],
|
||||
option: AskDisplayOption,
|
||||
isAnsweredOption: boolean
|
||||
): string {
|
||||
if (questionType !== "multi" || option.isCustomOption) {
|
||||
return "";
|
||||
}
|
||||
return `[${isAnsweredOption ? "x" : " "}] `;
|
||||
}
|
||||
|
||||
function renderInteractiveCustomOption(
|
||||
context: QuestionRenderContext,
|
||||
index: number,
|
||||
pointer: string,
|
||||
prefix: string,
|
||||
optionColor: ReturnType<typeof getOptionColor>
|
||||
lines: string[],
|
||||
row: OptionRowModel,
|
||||
context: QuestionRenderContext
|
||||
) {
|
||||
const { lines, editor, theme, width } = context;
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${prefix}Type your own`,
|
||||
width,
|
||||
theme,
|
||||
optionColor,
|
||||
pointer,
|
||||
" ".repeat(visibleWidth(pointer))
|
||||
`${row.index + 1}. ${row.prefix}${row.label}`,
|
||||
context.width,
|
||||
context.theme,
|
||||
row.color,
|
||||
row.pointer,
|
||||
" ".repeat(visibleWidth(row.pointer))
|
||||
);
|
||||
renderIndentedEditor(
|
||||
renderEditorWithIndent({
|
||||
lines,
|
||||
editor,
|
||||
width,
|
||||
theme,
|
||||
UI_TEXT.editorPlaceholderInput
|
||||
);
|
||||
editor: context.editor,
|
||||
width: context.width,
|
||||
theme: context.theme,
|
||||
indent: " ",
|
||||
padding: UI_DIMENSIONS.editorIndentedPadding,
|
||||
placeholder:
|
||||
row.detail?.kind === "editor" ? row.detail.placeholder : "Type answer...",
|
||||
});
|
||||
}
|
||||
|
||||
function getOptionColor(isAnsweredOption: boolean, selected: boolean) {
|
||||
if (isAnsweredOption) {
|
||||
return "success";
|
||||
function renderOptionDescription(
|
||||
lines: string[],
|
||||
description: string | undefined,
|
||||
width: number,
|
||||
theme: Theme
|
||||
) {
|
||||
if (!description) {
|
||||
return;
|
||||
}
|
||||
if (selected) {
|
||||
return "accent";
|
||||
}
|
||||
return "text";
|
||||
pushWrappedText(lines, description, width, theme, "muted", " ", " ");
|
||||
}
|
||||
|
||||
+62
-222
@@ -1,21 +1,13 @@
|
||||
import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { SUBMIT_CHOICES } from "../constants.ts";
|
||||
import { serializeAnswer } from "../state/answers.ts";
|
||||
import type { AskResult, AskState, AskStateAnswer } from "../types.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "./constants.ts";
|
||||
import { truncateToWidth } from "@mariozechner/pi-tui";
|
||||
import type { AskState } from "../types.ts";
|
||||
import { UI_TEXT } from "./constants.ts";
|
||||
import {
|
||||
getSavedNotePrefixes,
|
||||
mergeColumns,
|
||||
pushSavedNote,
|
||||
pushWrappedText,
|
||||
} from "./render-helpers.ts";
|
||||
import type { Theme } from "./render-types.ts";
|
||||
|
||||
type ReviewAnswer = AskResult["answers"][string] & {
|
||||
extraOptionNotes?: Array<{
|
||||
label: string;
|
||||
note: string;
|
||||
}>;
|
||||
};
|
||||
import { buildReviewScreenModel } from "./view-models/review.ts";
|
||||
|
||||
export function renderSubmitScreen(
|
||||
lines: string[],
|
||||
@@ -23,22 +15,19 @@ export function renderSubmitScreen(
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
const showAllNotes = state.activeSubmitActionIndex === 1;
|
||||
const actionColumnWidth = getSubmitActionColumnWidth();
|
||||
|
||||
if (shouldUseWideSubmitLayout(width, actionColumnWidth)) {
|
||||
const rightWidth = Math.max(1, width - actionColumnWidth - 2);
|
||||
const reviewLines = renderSubmitReviewLines(
|
||||
state,
|
||||
const model = buildReviewScreenModel(state, width);
|
||||
if (model.layout === "wide") {
|
||||
const rightWidth = Math.max(1, width - model.actionColumnWidth - 2);
|
||||
const reviewLines = renderSubmitReviewLines(model, theme, rightWidth);
|
||||
const actionLines = renderSubmitActions(
|
||||
model,
|
||||
theme,
|
||||
rightWidth,
|
||||
showAllNotes
|
||||
model.actionColumnWidth
|
||||
);
|
||||
const actionLines = renderSubmitActions(state, theme, actionColumnWidth);
|
||||
for (const line of mergeColumns(
|
||||
actionLines,
|
||||
reviewLines,
|
||||
actionColumnWidth,
|
||||
model.actionColumnWidth,
|
||||
width
|
||||
)) {
|
||||
lines.push(line);
|
||||
@@ -46,38 +35,25 @@ export function renderSubmitScreen(
|
||||
return;
|
||||
}
|
||||
|
||||
const reviewLines = renderSubmitReviewLines(
|
||||
state,
|
||||
theme,
|
||||
width,
|
||||
showAllNotes
|
||||
);
|
||||
const actionLines = renderSubmitActions(state, theme, width);
|
||||
const reviewLines = renderSubmitReviewLines(model, theme, width);
|
||||
const actionLines = renderSubmitActions(model, theme, width);
|
||||
lines.push(...reviewLines);
|
||||
lines.push("");
|
||||
lines.push(...actionLines);
|
||||
}
|
||||
|
||||
function renderSubmitReviewLines(
|
||||
state: AskState,
|
||||
model: ReturnType<typeof buildReviewScreenModel>,
|
||||
theme: Theme,
|
||||
width: number,
|
||||
showAllNotes: boolean
|
||||
width: number
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
pushWrappedText(lines, UI_TEXT.reviewTitle, width, theme, "accent", " ", " ");
|
||||
lines.push("");
|
||||
|
||||
for (let index = 0; index < state.questions.length; index++) {
|
||||
const question = state.questions[index];
|
||||
renderSubmittedQuestion(
|
||||
lines,
|
||||
question,
|
||||
toReviewAnswer(question, state.answers[question.id], showAllNotes),
|
||||
theme,
|
||||
width
|
||||
);
|
||||
if (index < state.questions.length - 1) {
|
||||
for (const [index, question] of model.questions.entries()) {
|
||||
renderReviewQuestion(lines, question, theme, width);
|
||||
if (index < model.questions.length - 1) {
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
@@ -85,225 +61,89 @@ function renderSubmitReviewLines(
|
||||
return lines;
|
||||
}
|
||||
|
||||
function getSubmitActionColumnWidth(): number {
|
||||
return Math.max(
|
||||
...SUBMIT_CHOICES.map((choice, index) =>
|
||||
visibleWidth(`❯ ${index + 1}. ${choice}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseWideSubmitLayout(
|
||||
width: number,
|
||||
actionColumnWidth: number
|
||||
): boolean {
|
||||
return (
|
||||
width >= UI_DIMENSIONS.submitWideMinWidth &&
|
||||
width - actionColumnWidth - 2 >= UI_DIMENSIONS.submitMinReviewWidth
|
||||
);
|
||||
}
|
||||
|
||||
function renderSubmittedQuestion(
|
||||
function renderReviewQuestion(
|
||||
lines: string[],
|
||||
question: AskState["questions"][number],
|
||||
answer: ReviewAnswer | undefined,
|
||||
question: ReturnType<typeof buildReviewScreenModel>["questions"][number],
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
pushWrappedText(lines, question.label, width, theme, "text", " ", " ");
|
||||
if (!answer) {
|
||||
if (question.unanswered) {
|
||||
lines.push(
|
||||
truncateToWidth(` ${theme.fg("dim", UI_TEXT.unanswered)}`, width)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
renderSubmittedAnswer(lines, answer, theme, width);
|
||||
}
|
||||
|
||||
function renderSubmittedAnswer(
|
||||
lines: string[],
|
||||
answer: ReviewAnswer,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
if (answer.note) {
|
||||
renderSubmittedNote(lines, answer.note, theme, width);
|
||||
}
|
||||
|
||||
if (shouldRenderAnswersIndividually(answer)) {
|
||||
renderSubmittedSelections(lines, answer, theme, width);
|
||||
renderExtraOptionNotes(lines, answer, theme, width);
|
||||
return;
|
||||
}
|
||||
|
||||
const answerText = answer.labels.join(", ");
|
||||
if (answerText) {
|
||||
pushWrappedText(
|
||||
if (question.note) {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
`→ ${answerText}`,
|
||||
note: question.note,
|
||||
width,
|
||||
theme,
|
||||
isCustomOnlyAnswer(answer) ? "text" : "success",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
indent: " ",
|
||||
});
|
||||
}
|
||||
|
||||
renderExtraOptionNotes(lines, answer, theme, width);
|
||||
}
|
||||
|
||||
function renderSubmittedSelections(
|
||||
lines: string[],
|
||||
answer: ReviewAnswer,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
for (let index = 0; index < answer.labels.length; index++) {
|
||||
const label = answer.labels[index];
|
||||
const value = answer.values[index] ?? label;
|
||||
for (const selection of question.selections ?? []) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`→ ${label}`,
|
||||
`→ ${selection.label}`,
|
||||
width,
|
||||
theme,
|
||||
"success",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
|
||||
const note = answer.optionNotes?.[value];
|
||||
if (note) {
|
||||
renderSubmittedNote(lines, note, theme, width);
|
||||
if (selection.note) {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: selection.note,
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldRenderAnswersIndividually(answer: ReviewAnswer): boolean {
|
||||
if (!answer.labels.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
answer.labels.length > 1 ||
|
||||
Boolean(answer.optionNotes && Object.keys(answer.optionNotes).length > 0)
|
||||
);
|
||||
}
|
||||
|
||||
function renderSubmittedNote(
|
||||
lines: string[],
|
||||
note: string,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent: " ",
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
note,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
}
|
||||
|
||||
function renderLabeledSubmittedNote(
|
||||
lines: string[],
|
||||
label: string,
|
||||
note: string,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
const { prefix, continuationPrefix } = getSavedNotePrefixes(theme, {
|
||||
indent: " ",
|
||||
label,
|
||||
});
|
||||
pushWrappedText(
|
||||
lines,
|
||||
note,
|
||||
width,
|
||||
theme,
|
||||
"muted",
|
||||
prefix,
|
||||
continuationPrefix
|
||||
);
|
||||
}
|
||||
|
||||
function renderExtraOptionNotes(
|
||||
lines: string[],
|
||||
answer: ReviewAnswer,
|
||||
theme: Theme,
|
||||
width: number
|
||||
) {
|
||||
for (const option of answer.extraOptionNotes ?? []) {
|
||||
renderLabeledSubmittedNote(lines, option.label, option.note, theme, width);
|
||||
}
|
||||
}
|
||||
|
||||
function isCustomOnlyAnswer(answer: AskResult["answers"][string]): boolean {
|
||||
return answer.indices.length === 0 && !!answer.customText;
|
||||
}
|
||||
|
||||
function toReviewAnswer(
|
||||
question: AskState["questions"][number],
|
||||
answer: AskStateAnswer | undefined,
|
||||
showAllNotes: boolean
|
||||
): ReviewAnswer | undefined {
|
||||
if (!answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const serialized = serializeAnswer(answer);
|
||||
const hasSelectedAnswer = serialized.labels.length > 0;
|
||||
if (!showAllNotes) {
|
||||
return hasSelectedAnswer ? serialized : undefined;
|
||||
}
|
||||
|
||||
const selectedValues = new Set(serialized.values);
|
||||
const extraOptionNotes = Object.entries(answer.optionNotes ?? {})
|
||||
.filter(([value, note]) => !selectedValues.has(value) && Boolean(note))
|
||||
.map(([value, note]) => {
|
||||
const option = question.options.find(
|
||||
(candidate) => candidate.value === value
|
||||
);
|
||||
return option ? { label: option.label, note } : undefined;
|
||||
})
|
||||
.filter((entry): entry is { label: string; note: string } =>
|
||||
Boolean(entry)
|
||||
if (question.answerText) {
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`→ ${question.answerText}`,
|
||||
width,
|
||||
theme,
|
||||
question.isCustomOnly ? "text" : "success",
|
||||
" ",
|
||||
" "
|
||||
);
|
||||
|
||||
if (
|
||||
!(hasSelectedAnswer || serialized.note) &&
|
||||
extraOptionNotes.length === 0
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
...serialized,
|
||||
extraOptionNotes:
|
||||
extraOptionNotes.length > 0 ? extraOptionNotes : undefined,
|
||||
};
|
||||
for (const optionNote of question.extraOptionNotes ?? []) {
|
||||
pushSavedNote({
|
||||
lines,
|
||||
note: optionNote.note,
|
||||
width,
|
||||
theme,
|
||||
indent: " ",
|
||||
label: optionNote.label,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderSubmitActions(
|
||||
state: AskState,
|
||||
model: ReturnType<typeof buildReviewScreenModel>,
|
||||
theme: Theme,
|
||||
width: number
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
for (let index = 0; index < SUBMIT_CHOICES.length; index++) {
|
||||
const selected = index === state.activeSubmitActionIndex;
|
||||
const prefix = selected ? "❯ " : " ";
|
||||
for (const [index, action] of model.actions.entries()) {
|
||||
const prefix = action.selected ? "❯ " : " ";
|
||||
pushWrappedText(
|
||||
lines,
|
||||
`${index + 1}. ${SUBMIT_CHOICES[index]}`,
|
||||
`${index + 1}. ${action.label}`,
|
||||
width,
|
||||
theme,
|
||||
selected ? "accent" : "text",
|
||||
action.selected ? "accent" : "text",
|
||||
prefix,
|
||||
prefix
|
||||
);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { isOptionSelected } from "../../state/answers.ts";
|
||||
import {
|
||||
getAnswer,
|
||||
getOptionNote,
|
||||
getQuestionNote,
|
||||
isInputOpenForQuestion,
|
||||
isOptionNoteOpen,
|
||||
isQuestionNoteOpen,
|
||||
} from "../../state/selectors.ts";
|
||||
import type { AskDisplayOption } from "../../types.ts";
|
||||
import { UI_DIMENSIONS, UI_TEXT } from "../constants.ts";
|
||||
import type { QuestionRenderContext } from "../render-types.ts";
|
||||
|
||||
export type QuestionNoteModel =
|
||||
| { kind: "editor"; placeholder: string }
|
||||
| { kind: "saved"; text: string };
|
||||
|
||||
export type OptionDetailModel =
|
||||
| { kind: "editor"; placeholder: string; withGap: boolean }
|
||||
| { kind: "saved-note"; text: string; withGap: boolean }
|
||||
| { kind: "custom-text"; text: string; withGap: boolean };
|
||||
|
||||
export interface OptionRowModel {
|
||||
color: "accent" | "text" | "success";
|
||||
description?: string;
|
||||
detail?: OptionDetailModel;
|
||||
index: number;
|
||||
isCustom: boolean;
|
||||
label: string;
|
||||
pointer: string;
|
||||
prefix: string;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
export interface QuestionScreenModel {
|
||||
mode: "preview" | "standard";
|
||||
previewLayout?: "custom" | "stacked" | "wide";
|
||||
questionNote?: QuestionNoteModel;
|
||||
rows: OptionRowModel[];
|
||||
selectedOption?: AskDisplayOption;
|
||||
selectedOptionDetail?: OptionDetailModel;
|
||||
}
|
||||
|
||||
export function buildQuestionScreenModel(
|
||||
context: QuestionRenderContext
|
||||
): QuestionScreenModel {
|
||||
const rows = context.options.map((option, index) =>
|
||||
buildOptionRowModel(context, option, index)
|
||||
);
|
||||
const questionNote = buildQuestionNoteModel(context);
|
||||
if (context.question.type !== "preview") {
|
||||
return {
|
||||
mode: "standard",
|
||||
questionNote,
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
const selectedOption = context.options[context.state.activeOptionIndex];
|
||||
return {
|
||||
mode: "preview",
|
||||
previewLayout: getPreviewLayout(context, selectedOption),
|
||||
questionNote,
|
||||
rows,
|
||||
selectedOption,
|
||||
selectedOptionDetail: buildOptionDetailModel(context, selectedOption, true),
|
||||
};
|
||||
}
|
||||
|
||||
function buildQuestionNoteModel(
|
||||
context: QuestionRenderContext
|
||||
): QuestionNoteModel | undefined {
|
||||
const { state, question } = context;
|
||||
if (isQuestionNoteOpen(state, question.id)) {
|
||||
return { kind: "editor", placeholder: UI_TEXT.editorPlaceholderNote };
|
||||
}
|
||||
const note = getQuestionNote(state, question.id);
|
||||
return note ? { kind: "saved", text: note } : undefined;
|
||||
}
|
||||
|
||||
function buildOptionRowModel(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption,
|
||||
index: number
|
||||
): OptionRowModel {
|
||||
const { question, state } = context;
|
||||
const answer = getAnswer(state, question.id);
|
||||
const selected = index === state.activeOptionIndex;
|
||||
const answered = option.isCustomOption
|
||||
? !!answer?.customText
|
||||
: isOptionSelected(answer, option.value);
|
||||
return {
|
||||
color: getOptionColor(answered, selected),
|
||||
description: option.description,
|
||||
detail:
|
||||
context.question.type === "preview"
|
||||
? undefined
|
||||
: buildOptionDetailModel(context, option, selected),
|
||||
index,
|
||||
isCustom: !!option.isCustomOption,
|
||||
label: option.label,
|
||||
pointer: selected ? "❯ " : " ",
|
||||
prefix: getOptionPrefix(question.type, option, answered),
|
||||
selected,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOptionDetailModel(
|
||||
context: QuestionRenderContext,
|
||||
option: AskDisplayOption | undefined,
|
||||
selected: boolean
|
||||
): OptionDetailModel | undefined {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { state, question } = context;
|
||||
const answer = getAnswer(state, question.id);
|
||||
const inputOpen =
|
||||
!!option.isCustomOption &&
|
||||
selected &&
|
||||
isInputOpenForQuestion(state, question.id);
|
||||
const noteOpen =
|
||||
!option.isCustomOption &&
|
||||
isOptionNoteOpen(state, question.id, option.value);
|
||||
const customText = option.isCustomOption ? answer?.customText : undefined;
|
||||
const note = option.isCustomOption
|
||||
? undefined
|
||||
: getOptionNote(state, question.id, option.value);
|
||||
const withGap = !option.isCustomOption;
|
||||
|
||||
if (noteOpen) {
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderNote,
|
||||
withGap,
|
||||
};
|
||||
}
|
||||
if (inputOpen) {
|
||||
return {
|
||||
kind: "editor",
|
||||
placeholder: UI_TEXT.editorPlaceholderInput,
|
||||
withGap,
|
||||
};
|
||||
}
|
||||
if (customText) {
|
||||
return { kind: "custom-text", text: customText, withGap };
|
||||
}
|
||||
if (note) {
|
||||
return { kind: "saved-note", text: note, withGap };
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
function getPreviewLayout(
|
||||
context: QuestionRenderContext,
|
||||
selectedOption: AskDisplayOption | undefined
|
||||
): "custom" | "stacked" | "wide" {
|
||||
if (selectedOption?.isCustomOption) {
|
||||
return "custom";
|
||||
}
|
||||
return context.width >= UI_DIMENSIONS.previewWideMinWidth &&
|
||||
context.options.length > 0
|
||||
? "wide"
|
||||
: "stacked";
|
||||
}
|
||||
|
||||
function getOptionPrefix(
|
||||
questionType: QuestionRenderContext["question"]["type"],
|
||||
option: AskDisplayOption,
|
||||
answered: boolean
|
||||
): string {
|
||||
if (questionType !== "multi" || option.isCustomOption) {
|
||||
return "";
|
||||
}
|
||||
return `[${answered ? "x" : " "}] `;
|
||||
}
|
||||
|
||||
function getOptionColor(
|
||||
answered: boolean,
|
||||
selected: boolean
|
||||
): "accent" | "text" | "success" {
|
||||
if (answered) {
|
||||
return "success";
|
||||
}
|
||||
if (selected) {
|
||||
return "accent";
|
||||
}
|
||||
return "text";
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { visibleWidth } from "@mariozechner/pi-tui";
|
||||
import { SUBMIT_CHOICES } from "../../constants.ts";
|
||||
import { isCustomOnlyAnswer } from "../../state/answers.ts";
|
||||
import {
|
||||
type ReviewAnswer,
|
||||
shouldRenderAnswersIndividually,
|
||||
toReviewAnswer,
|
||||
} from "../../state/result.ts";
|
||||
import type { AskState } from "../../types.ts";
|
||||
import { UI_DIMENSIONS } from "../constants.ts";
|
||||
|
||||
export interface ReviewSelectionModel {
|
||||
label: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface ReviewQuestionModel {
|
||||
answerText?: string;
|
||||
extraOptionNotes?: Array<{ label: string; note: string }>;
|
||||
isCustomOnly?: boolean;
|
||||
label: string;
|
||||
note?: string;
|
||||
selections?: ReviewSelectionModel[];
|
||||
unanswered: boolean;
|
||||
}
|
||||
|
||||
export interface ReviewScreenModel {
|
||||
actionColumnWidth: number;
|
||||
actions: Array<{ label: string; selected: boolean }>;
|
||||
layout: "stacked" | "wide";
|
||||
questions: ReviewQuestionModel[];
|
||||
}
|
||||
|
||||
export function buildReviewScreenModel(
|
||||
state: AskState,
|
||||
width: number
|
||||
): ReviewScreenModel {
|
||||
const showAllNotes = state.activeSubmitActionIndex === 1;
|
||||
const actionColumnWidth = getSubmitActionColumnWidth();
|
||||
return {
|
||||
actionColumnWidth,
|
||||
actions: SUBMIT_CHOICES.map((label, index) => ({
|
||||
label,
|
||||
selected: index === state.activeSubmitActionIndex,
|
||||
})),
|
||||
layout: shouldUseWideSubmitLayout(width, actionColumnWidth)
|
||||
? "wide"
|
||||
: "stacked",
|
||||
questions: state.questions.map((question) =>
|
||||
toReviewQuestionModel(
|
||||
question.label,
|
||||
toReviewAnswer(question, state.answers[question.id], showAllNotes)
|
||||
)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function toReviewQuestionModel(
|
||||
label: string,
|
||||
answer: ReviewAnswer | undefined
|
||||
): ReviewQuestionModel {
|
||||
if (!answer) {
|
||||
return { label, unanswered: true };
|
||||
}
|
||||
|
||||
return {
|
||||
answerText: shouldRenderAnswersIndividually(answer)
|
||||
? undefined
|
||||
: answer.labels.join(", "),
|
||||
extraOptionNotes: answer.extraOptionNotes,
|
||||
isCustomOnly: isCustomOnlyAnswer(answer),
|
||||
label,
|
||||
note: answer.note,
|
||||
selections: shouldRenderAnswersIndividually(answer)
|
||||
? answer.labels.map((selectionLabel, index) => ({
|
||||
label: selectionLabel,
|
||||
note: answer.optionNotes?.[answer.values[index] ?? selectionLabel],
|
||||
}))
|
||||
: undefined,
|
||||
unanswered: false,
|
||||
};
|
||||
}
|
||||
|
||||
function getSubmitActionColumnWidth(): number {
|
||||
return Math.max(
|
||||
...SUBMIT_CHOICES.map((choice, index) =>
|
||||
visibleWidth(`❯ ${index + 1}. ${choice}`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseWideSubmitLayout(
|
||||
width: number,
|
||||
actionColumnWidth: number
|
||||
): boolean {
|
||||
return (
|
||||
width >= UI_DIMENSIONS.submitWideMinWidth &&
|
||||
width - actionColumnWidth - 2 >= UI_DIMENSIONS.submitMinReviewWidth
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user