From d21ab3fd30005367722b90c9e1b681ae9d827aeb Mon Sep 17 00:00:00 2001 From: MDietermann Date: Mon, 13 Apr 2026 21:52:17 +0200 Subject: [PATCH] feat(tui): add six-screen Huh wizard for installer input --- internal/tui/wizard/wizard.go | 158 +++++++++++++++++++++++++++++ internal/tui/wizard/wizard_test.go | 74 ++++++++++++++ 2 files changed, 232 insertions(+) create mode 100644 internal/tui/wizard/wizard.go create mode 100644 internal/tui/wizard/wizard_test.go diff --git a/internal/tui/wizard/wizard.go b/internal/tui/wizard/wizard.go new file mode 100644 index 0000000..5f03194 --- /dev/null +++ b/internal/tui/wizard/wizard.go @@ -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 +} diff --git a/internal/tui/wizard/wizard_test.go b/internal/tui/wizard/wizard_test.go new file mode 100644 index 0000000..94999fe --- /dev/null +++ b/internal/tui/wizard/wizard_test.go @@ -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") + } +}