package wizard import ( "context" "errors" "fmt" "strconv" "strings" "github.com/charmbracelet/huh" "github.com/MDietermann/ArduinoArchSetup/internal/module" "github.com/MDietermann/ArduinoArchSetup/internal/theming" "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 // Honour an already-cancelled context before Huh tries to open /dev/tty. if err := ctx.Err(); err != nil { return s, false, nil } themes := theming.List() themeOptions := make([]huh.Option[string], 0, len(themes)) for _, t := range themes { themeOptions = append(themeOptions, huh.NewOption(t.DisplayName, 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 cache := newPlanCache() 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 renderPlanCached(ctx, reg, s.SelectedIDs(), cache) }, &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 } // Redundant-but-safe: the DescriptionFunc closure on the plan-review // screen already kept s.Modules in sync. The MultiSelect group is // frozen by the time we reach here, so this is idempotent. Kept for // the case where the form is exited via a path that bypasses the // plan-review screen in future revisions. s.Modules = syncSelected(s.Modules, selectedModules) s.Parallelism = parseIntClamp(parallelismStr, 1) 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 } // planCache memoises rendered Detect+Plan output per module ID so // DescriptionFunc (which fires on every redraw) does not re-run // potentially expensive Detect/Plan calls. Entries are evicted when a // module leaves the current selection. type planCacheEntry struct{ out string } type planCache struct{ entries map[string]planCacheEntry } func newPlanCache() *planCache { return &planCache{entries: map[string]planCacheEntry{}} } func (c *planCache) get(id string) (string, bool) { e, ok := c.entries[id] return e.out, ok } func (c *planCache) put(id, out string) { c.entries[id] = planCacheEntry{out: out} } func (c *planCache) prune(keepIDs []string) { keep := map[string]bool{} for _, id := range keepIDs { keep[id] = true } for k := range c.entries { if !keep[k] { delete(c.entries, k) } } } // renderPlanCached is the cached equivalent of running Detect+Plan for // every selected module and joining the rendered lines. IDs no longer in // the selection are evicted so a deselect+reselect re-runs Detect. func renderPlanCached(ctx context.Context, reg *module.Registry, selectedIDs []string, cache *planCache) string { cache.prune(selectedIDs) if len(selectedIDs) == 0 { return "No modules selected." } var b strings.Builder for _, id := range selectedIDs { if cached, ok := cache.get(id); ok { b.WriteString(cached) continue } line := renderOne(ctx, reg, id) cache.put(id, line) b.WriteString(line) } return b.String() } // renderOne runs Detect+Plan for a single module and returns a rendered // line. A failing Detect/Plan is shown inline but does not abort. func renderOne(ctx context.Context, reg *module.Registry, id string) string { m, ok := reg.Get(id) if !ok { return fmt.Sprintf("• %s — (unknown)\n", id) } state, err := m.Detect(ctx) if err != nil { return fmt.Sprintf("• %s — detect failed: %v\n", id, err) } plan, err := m.Plan(ctx, state) if err != nil { return fmt.Sprintf("• %s — plan failed: %v\n", id, err) } if plan.IsEmpty() { return fmt.Sprintf("• %s — already up to date\n", id) } var b strings.Builder 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 a positive int. Empty, non-numeric (including // partial matches like "3abc"), negative, zero, or overflowing input all // return fallback. strconv.Atoi (not fmt.Sscanf) is used so partial matches // are rejected and overflow surfaces as an error. func parseIntClamp(s string, fallback int) int { s = strings.TrimSpace(s) if s == "" { return fallback } n, err := strconv.Atoi(s) if err != nil || n < 1 { return fallback } return n }