Files
Archduino/internal/tui/wizard/wizard.go

164 lines
4.4 KiB
Go

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
// Honour an already-cancelled context before Huh tries to open /dev/tty.
if err := ctx.Err(); err != nil {
return s, false, nil
}
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(&parallelismStr),
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
}