diff --git a/src/setup-ui-keys.test.ts b/src/setup-ui-keys.test.ts new file mode 100644 index 0000000..ff0564c --- /dev/null +++ b/src/setup-ui-keys.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test"; +import { isSubmitKey, parseChoiceShortcut } from "./setup-ui-keys.js"; + +describe("setup UI key handling", () => { + test("accepts common submit key names", () => { + expect(isSubmitKey("return")).toBe(true); + expect(isSubmitKey("linefeed")).toBe(true); + expect(isSubmitKey("enter")).toBe(true); + expect(isSubmitKey("space")).toBe(false); + }); + + test("maps numeric shortcuts to zero-based choice indexes", () => { + expect(parseChoiceShortcut("1")).toBe(0); + expect(parseChoiceShortcut("2")).toBe(1); + expect(parseChoiceShortcut("9")).toBe(8); + expect(parseChoiceShortcut("0")).toBeUndefined(); + expect(parseChoiceShortcut("x")).toBeUndefined(); + }); +}); diff --git a/src/setup-ui-keys.ts b/src/setup-ui-keys.ts new file mode 100644 index 0000000..37830a9 --- /dev/null +++ b/src/setup-ui-keys.ts @@ -0,0 +1,11 @@ +export function isSubmitKey(name: string): boolean { + return name === "return" || name === "linefeed" || name === "enter"; +} + +export function parseChoiceShortcut(name: string): number | undefined { + if (!/^[1-9]$/.test(name)) { + return undefined; + } + + return Number.parseInt(name, 10) - 1; +} diff --git a/src/setup-ui.tsx b/src/setup-ui.tsx index 2c03808..cb6f273 100644 --- a/src/setup-ui.tsx +++ b/src/setup-ui.tsx @@ -1,6 +1,6 @@ import { createCliRenderer } from "@opentui/core"; import { render, useKeyboard } from "@opentui/solid"; -import { createMemo, createSignal, For } from "solid-js"; +import { createEffect, createMemo, createSignal, For } from "solid-js"; import { applySetupState, type SetupContext, @@ -9,6 +9,7 @@ import { type SetupState, summarizeSetupContext, } from "./setup-core.js"; +import { isSubmitKey, parseChoiceShortcut } from "./setup-ui-keys.js"; import { getNextWizardStepIndex } from "./setup-ui-state.js"; type WizardChoice = { @@ -357,6 +358,7 @@ export function SetupWizard(props: { ); const [errorMessage, setErrorMessage] = createSignal(); const [result, setResult] = createSignal(); + const [summaryActionIndex, setSummaryActionIndex] = createSignal(0); const steps = createMemo(() => buildSteps({ ...state() })); const activeStep = createMemo(() => steps()[Math.min(stepIndex(), steps().length - 1)]); @@ -377,6 +379,12 @@ export function SetupWizard(props: { return index >= 0 ? index : 0; }); + createEffect(() => { + if (activeStep().kind === "summary") { + setSummaryActionIndex(0); + } + }); + const goBack = (): void => { if (phase() === "saving") { return; @@ -436,6 +444,68 @@ export function SetupWizard(props: { } }; + const updateChoiceSelection = (offset: -1 | 1): void => { + const step = activeChoiceStep(); + if (!step) { + return; + } + + const nextIndex = Math.max(0, Math.min(activeChoiceIndex() + offset, step.options.length - 1)); + const option = step.options[nextIndex]; + if (!option) { + return; + } + + setState((current) => { + const next = { ...current }; + step.commit(next, option.value); + return next; + }); + }; + + const commitChoiceAndAdvance = (): void => { + const step = activeChoiceStep(); + if (!step) { + return; + } + + const option = step.options[activeChoiceIndex()]; + if (!option) { + return; + } + + commitAndAdvance(() => { + setState((current) => { + const next = { ...current }; + step.commit(next, option.value); + return next; + }); + }); + }; + + const triggerSummaryAction = (): void => { + if (summaryActionIndex() === 1) { + props.cancel(new Error("Setup cancelled.")); + return; + } + + void save(); + }; + + const triggerSummaryShortcut = (index: number): void => { + if (index < 0 || index > 1) { + return; + } + + setSummaryActionIndex(index); + if (index === 1) { + props.cancel(new Error("Setup cancelled.")); + return; + } + + void save(); + }; + useKeyboard( (event: { ctrl: boolean; @@ -457,7 +527,81 @@ export function SetupWizard(props: { return; } - if ((phase() === "done" || phase() === "error") && event.name === "return") { + if (phase() === "wizard" && activeStep().kind === "choice") { + const shortcutIndex = parseChoiceShortcut(event.name); + if (shortcutIndex !== undefined) { + const step = activeChoiceStep(); + const option = step?.options[shortcutIndex]; + if (!option || !step) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + commitAndAdvance(() => { + setState((current) => { + const next = { ...current }; + step.commit(next, option.value); + return next; + }); + }); + return; + } + + if (event.name === "left") { + event.preventDefault(); + event.stopPropagation(); + updateChoiceSelection(-1); + return; + } + + if (event.name === "right") { + event.preventDefault(); + event.stopPropagation(); + updateChoiceSelection(1); + return; + } + + if (isSubmitKey(event.name)) { + event.preventDefault(); + event.stopPropagation(); + commitChoiceAndAdvance(); + return; + } + } + + if (phase() === "wizard" && activeStep().kind === "summary") { + const shortcutIndex = parseChoiceShortcut(event.name); + if (shortcutIndex !== undefined) { + event.preventDefault(); + event.stopPropagation(); + triggerSummaryShortcut(shortcutIndex); + return; + } + + if (event.name === "left") { + event.preventDefault(); + event.stopPropagation(); + setSummaryActionIndex(0); + return; + } + + if (event.name === "right") { + event.preventDefault(); + event.stopPropagation(); + setSummaryActionIndex(1); + return; + } + + if (isSubmitKey(event.name)) { + event.preventDefault(); + event.stopPropagation(); + triggerSummaryAction(); + return; + } + } + + if ((phase() === "done" || phase() === "error") && isSubmitKey(event.name)) { event.preventDefault(); event.stopPropagation(); goBack(); @@ -554,30 +698,26 @@ export function SetupWizard(props: { {(line) => {line}} - { - if (option?.value === "cancel") { - props.cancel(new Error("Setup cancelled.")); - return; - } - void save(); - }} - /> + + + + {(label, index) => ( + + {" "} + {label}{" "} + + )} + + + + {summaryActionIndex() === 0 + ? "Write config and finish setup." + : "Exit without writing files."} + + ) : ( (() => { @@ -591,34 +731,26 @@ export function SetupWizard(props: { {choiceStep.description} {choiceStep.hint} - { - if (!option) { - return; - } - setState((current) => { - const next = { ...current }; - choiceStep.commit(next, option.value); - return next; - }); - }} - onSelect={(_index: number, option: WizardChoice | null) => { - if (!option) { - return; - } - commitAndAdvance(() => { - setState((current) => { - const next = { ...current }; - choiceStep.commit(next, option.value); - return next; - }); - }); - }} - /> + + + + {(option, index) => ( + + {" "} + {option.name}{" "} + + )} + + + + {choiceStep.options[activeChoiceIndex()]?.description ?? ""} + + ); } @@ -713,7 +845,9 @@ export function SetupWizard(props: { ) : null} - Esc goes back. Ctrl+C exits setup immediately. + + Esc goes back. Ctrl+C exits setup immediately. 1-9 selects a visible choice. +