feat(tui): add six-screen Huh wizard for installer input
This commit is contained in:
158
internal/tui/wizard/wizard.go
Normal file
158
internal/tui/wizard/wizard.go
Normal file
@@ -0,0 +1,158 @@
|
||||
package wizard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/huh"
|
||||
|
||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||
)
|
||||
|
||||
// Run presents the six-screen wizard. Returns the final state and whether
|
||||
// the user confirmed. A context cancellation returns (state, false, nil).
|
||||
func Run(ctx context.Context, reg *module.Registry, initial tui.WizardState) (tui.WizardState, bool, error) {
|
||||
s := initial
|
||||
|
||||
themeOptions := make([]huh.Option[string], 0, len(tui.BuiltinThemes))
|
||||
for _, t := range tui.BuiltinThemes {
|
||||
themeOptions = append(themeOptions, huh.NewOption(t.Name, t.ID))
|
||||
}
|
||||
|
||||
moduleOptions := make([]huh.Option[string], 0, len(s.Modules))
|
||||
defaults := make([]string, 0, len(s.Modules))
|
||||
for _, m := range s.Modules {
|
||||
label := m.Name
|
||||
if m.Description != "" {
|
||||
label = fmt.Sprintf("%s — %s", m.Name, m.Description)
|
||||
}
|
||||
moduleOptions = append(moduleOptions, huh.NewOption(label, m.ID))
|
||||
if m.Selected {
|
||||
defaults = append(defaults, m.ID)
|
||||
}
|
||||
}
|
||||
|
||||
selectedModules := defaults
|
||||
parallelismStr := fmt.Sprintf("%d", s.Parallelism)
|
||||
var proceed bool
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(huh.NewNote().
|
||||
Title("Archduino Installer").
|
||||
Description("A TUI Arduino development environment.\n\nPress Enter to continue.")),
|
||||
|
||||
huh.NewGroup(huh.NewSelect[string]().
|
||||
Title("Pick a colorscheme").
|
||||
Options(themeOptions...).
|
||||
Value(&s.ThemeID)),
|
||||
|
||||
huh.NewGroup(huh.NewMultiSelect[string]().
|
||||
Title("Select modules to install").
|
||||
Options(moduleOptions...).
|
||||
Value(&selectedModules)),
|
||||
|
||||
huh.NewGroup(
|
||||
huh.NewInput().
|
||||
Title("Parallelism (advanced)").
|
||||
Description("How many modules to run concurrently. 1 is safe.").
|
||||
Value(¶llelismStr),
|
||||
huh.NewConfirm().
|
||||
Title("Halt on first error?").
|
||||
Value(&s.HaltOnError),
|
||||
),
|
||||
|
||||
huh.NewGroup(huh.NewNote().
|
||||
Title("Review plan").
|
||||
DescriptionFunc(func() string {
|
||||
s.Modules = syncSelected(s.Modules, selectedModules)
|
||||
return renderPlan(ctx, reg, s.SelectedIDs())
|
||||
}, &selectedModules)),
|
||||
|
||||
huh.NewGroup(huh.NewConfirm().
|
||||
Title("Proceed with installation?").
|
||||
Value(&proceed)),
|
||||
)
|
||||
|
||||
if err := form.RunWithContext(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, huh.ErrUserAborted) {
|
||||
return s, false, nil
|
||||
}
|
||||
return s, false, err
|
||||
}
|
||||
|
||||
s.Modules = syncSelected(s.Modules, selectedModules)
|
||||
// Parse parallelism: clamp to 1 on any parse failure, since the wizard
|
||||
// uses a free-form Input; a stricter schema can come later.
|
||||
if n, perr := parseIntClamp(parallelismStr, 1); perr == nil {
|
||||
s.Parallelism = n
|
||||
}
|
||||
s.Confirmed = proceed
|
||||
return s, proceed, nil
|
||||
}
|
||||
|
||||
// syncSelected returns a copy of mods with Selected set to whether the ID
|
||||
// appears in selected.
|
||||
func syncSelected(mods []tui.ModuleChoice, selected []string) []tui.ModuleChoice {
|
||||
set := map[string]bool{}
|
||||
for _, id := range selected {
|
||||
set[id] = true
|
||||
}
|
||||
out := make([]tui.ModuleChoice, len(mods))
|
||||
copy(out, mods)
|
||||
for i := range out {
|
||||
out[i].Selected = set[out[i].ID]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderPlan runs Detect + Plan for every selected module and renders a
|
||||
// human-readable summary. A failing Detect/Plan is shown inline but does
|
||||
// not abort the wizard.
|
||||
func renderPlan(ctx context.Context, reg *module.Registry, selectedIDs []string) string {
|
||||
if len(selectedIDs) == 0 {
|
||||
return "No modules selected."
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, id := range selectedIDs {
|
||||
m, ok := reg.Get(id)
|
||||
if !ok {
|
||||
fmt.Fprintf(&b, "• %s — (unknown)\n", id)
|
||||
continue
|
||||
}
|
||||
state, err := m.Detect(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&b, "• %s — detect failed: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
plan, err := m.Plan(ctx, state)
|
||||
if err != nil {
|
||||
fmt.Fprintf(&b, "• %s — plan failed: %v\n", id, err)
|
||||
continue
|
||||
}
|
||||
if plan.IsEmpty() {
|
||||
fmt.Fprintf(&b, "• %s — already up to date\n", id)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "• %s\n", id)
|
||||
for _, a := range plan.Actions {
|
||||
fmt.Fprintf(&b, " - %s\n", a.Description)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseIntClamp parses s as int. Empty or invalid input returns fallback.
|
||||
func parseIntClamp(s string, fallback int) (int, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback, nil
|
||||
}
|
||||
var n int
|
||||
if _, err := fmt.Sscanf(s, "%d", &n); err != nil || n < 1 {
|
||||
return fallback, nil
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
74
internal/tui/wizard/wizard_test.go
Normal file
74
internal/tui/wizard/wizard_test.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package wizard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/MDietermann/ArduinoArchSetup/internal/module"
|
||||
"github.com/MDietermann/ArduinoArchSetup/internal/modules/hello"
|
||||
"github.com/MDietermann/ArduinoArchSetup/internal/tui"
|
||||
)
|
||||
|
||||
func TestSyncSelected(t *testing.T) {
|
||||
orig := []tui.ModuleChoice{
|
||||
{ID: "a", Selected: false},
|
||||
{ID: "b", Selected: true},
|
||||
{ID: "c", Selected: true},
|
||||
}
|
||||
got := syncSelected(orig, []string{"a", "c"})
|
||||
want := map[string]bool{"a": true, "b": false, "c": true}
|
||||
for _, m := range got {
|
||||
if m.Selected != want[m.ID] {
|
||||
t.Errorf("%s selected=%v, want %v", m.ID, m.Selected, want[m.ID])
|
||||
}
|
||||
}
|
||||
if orig[0].Selected {
|
||||
t.Error("orig mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPlanWithHello(t *testing.T) {
|
||||
reg := module.NewRegistry()
|
||||
_ = reg.Register(hello.Module{Path: t.TempDir() + "/marker"})
|
||||
out := renderPlan(context.Background(), reg, []string{"hello"})
|
||||
if !strings.Contains(out, "hello") {
|
||||
t.Fatalf("output missing hello: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPlanEmpty(t *testing.T) {
|
||||
if got := renderPlan(context.Background(), module.NewRegistry(), nil); got != "No modules selected." {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntClamp(t *testing.T) {
|
||||
cases := map[string]int{
|
||||
"": 1,
|
||||
" ": 1,
|
||||
"abc": 1,
|
||||
"0": 1,
|
||||
"-5": 1,
|
||||
"3": 3,
|
||||
"12": 12,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, _ := parseIntClamp(in, 1)
|
||||
if got != want {
|
||||
t.Errorf("parseIntClamp(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunCancelledContextReturnsNoConfirmation(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, confirmed, err := Run(ctx, module.NewRegistry(), tui.WizardState{})
|
||||
if err != nil && err != context.Canceled {
|
||||
t.Fatalf("unexpected err: %v", err)
|
||||
}
|
||||
if confirmed {
|
||||
t.Fatal("confirmed=true on cancelled context")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user