package cli import ( "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/hachem-wtf/benson/internal/config" "github.com/hachem-wtf/benson/internal/modrinth" ) const ( stageMenu = iota stageQuery stageResults stageVersion stageRemove ) type modManagerMsg struct { kind string hits []modrinth.SearchHit versions []modrinth.Version query string err error } func runMods(m *model) tea.Cmd { m.openMenu() return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) } func (m *model) updatePicker(msg tea.Msg) (tea.Model, tea.Cmd) { if size, ok := msg.(tea.WindowSizeMsg); ok { m.width, m.height = size.Width, size.Height } form, cmd := m.form.Update(msg) if f, ok := form.(*huh.Form); ok { m.form = f } switch m.form.State { case huh.StateAborted: if m.pickerStage == stageMenu { m.form = nil return m, nil } return m, m.reopenMenu() case huh.StateCompleted: return m, m.advance() } return m, cmd } func (m *model) advance() tea.Cmd { switch m.pickerStage { case stageMenu: switch m.pickerAction { case "add": m.openQuery() return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) case "remove": m.openRemove() return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) default: m.form = nil return tea.Println(renderModList(m.cfg)) } case stageQuery: query := strings.TrimSpace(m.pickerQuery) m.form = nil if query == "" { return m.reopenMenu() } return m.searchCmd(query) case stageResults: slug := m.pickerSlug m.form = nil return m.versionsCmd(slug) case stageVersion: m.cfg.ModrinthMods = upsertMod(m.cfg.ModrinthMods, config.Mod{Slug: m.pickerSlug, Version: m.pickerVersion}) return m.saveThenMenu(successStyle.Render("Added ") + m.pickerSlug + mutedStyle.Render(" ("+versionTag(m.pickerVersion)+")")) case stageRemove: m.cfg.ModrinthMods = removeMod(m.cfg.ModrinthMods, m.pickerSlug) return m.saveThenMenu(successStyle.Render("Removed ") + m.pickerSlug) } return nil } func (m *model) searchCmd(query string) tea.Cmd { m.busy = true mc := m.cfg.MinecraftVersion return tea.Batch(m.spin.Tick, func() tea.Msg { hits, err := modrinth.NewClient().Search(query, mc, "fabric") return modManagerMsg{kind: "search", hits: hits, query: query, err: err} }) } func (m *model) versionsCmd(slug string) tea.Cmd { m.busy = true return tea.Batch(m.spin.Tick, func() tea.Msg { versions, err := modrinth.NewClient().GetVersions(slug) return modManagerMsg{kind: "versions", versions: versions, err: err} }) } func (m *model) handleModManager(msg modManagerMsg) tea.Cmd { if msg.err != nil { return tea.Sequence(tea.Println(errorStyle.Render("Error: "+msg.err.Error())), m.reopenMenu()) } switch msg.kind { case "search": if len(msg.hits) == 0 { return tea.Sequence(tea.Println(mutedStyle.Render("No results for “"+msg.query+"”.")), m.reopenMenu()) } m.openResults(msg.hits) case "versions": m.openVersion(msg.versions) } return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) } func (m *model) saveThenMenu(notice string) tea.Cmd { if err := config.Save(m.cfg); err != nil { return tea.Sequence(tea.Println(errorStyle.Render("Failed to save config: "+err.Error())), m.reopenMenu()) } return tea.Sequence(tea.Println(notice), m.reopenMenu()) } func (m *model) reopenMenu() tea.Cmd { m.openMenu() return tea.Batch(m.form.Init(), sizeCmd(m.width, m.height)) } func (m *model) openMenu() { m.pickerStage = stageMenu m.pickerAction = "add" options := []huh.Option[string]{huh.NewOption("Search & add a mod", "add")} if len(m.cfg.ModrinthMods) > 0 { options = append(options, huh.NewOption("Remove a mod", "remove")) } options = append(options, huh.NewOption("Done", "done")) m.form = huh.NewForm(huh.NewGroup( huh.NewSelect[string](). Title(fmt.Sprintf("Mods · %d configured", len(m.cfg.ModrinthMods))). Description(modsSummary(m.cfg.ModrinthMods)). Options(options...). Value(&m.pickerAction), )).WithTheme(pickerTheme()) } func (m *model) openQuery() { m.pickerStage = stageQuery m.pickerQuery = "" m.form = huh.NewForm(huh.NewGroup( huh.NewInput(). Title("Search Modrinth"). Description("Mod name or keyword · esc to go back"). Value(&m.pickerQuery), )).WithTheme(pickerTheme()) } func (m *model) openResults(hits []modrinth.SearchHit) { m.pickerStage = stageResults m.pickerSlug = hits[0].Slug options := make([]huh.Option[string], len(hits)) for i, h := range hits { options[i] = huh.NewOption(h.Title+" ("+h.Slug+")", h.Slug) } m.form = huh.NewForm(huh.NewGroup( huh.NewSelect[string](). Title("Add a mod"). Description("Pick a search result · esc to go back"). Options(options...). Height(selectHeight(len(options))). Value(&m.pickerSlug), )).WithTheme(pickerTheme()) } func (m *model) openVersion(versions []modrinth.Version) { m.pickerStage = stageVersion m.pickerVersion = "" options := []huh.Option[string]{huh.NewOption("Latest compatible · recommended", "")} for _, v := range versions { if containsStr(v.Loaders, "fabric") && containsStr(v.GameVersions, m.cfg.MinecraftVersion) { options = append(options, huh.NewOption(v.VersionNumber, v.VersionNumber)) } } m.form = huh.NewForm(huh.NewGroup( huh.NewSelect[string](). Title("Version for " + m.pickerSlug). Description("Keep it on latest, or pin one"). Options(options...). Height(selectHeight(len(options))). Value(&m.pickerVersion), )).WithTheme(pickerTheme()) } func (m *model) openRemove() { m.pickerStage = stageRemove m.pickerSlug = m.cfg.ModrinthMods[0].Slug options := make([]huh.Option[string], len(m.cfg.ModrinthMods)) for i, mod := range m.cfg.ModrinthMods { options[i] = huh.NewOption(modLabel(mod), mod.Slug) } m.form = huh.NewForm(huh.NewGroup( huh.NewSelect[string](). Title("Remove which mod?"). Description("esc to go back"). Options(options...). Height(selectHeight(len(options))). Value(&m.pickerSlug), )).WithTheme(pickerTheme()) } func renderModList(cfg *config.Config) string { rows := []string{headingStyle.Render(fmt.Sprintf("Mods for MC %s", cfg.MinecraftVersion)), ""} if len(cfg.ModrinthMods) == 0 { rows = append(rows, mutedStyle.Render(" (none configured)")) } for _, mod := range cfg.ModrinthMods { tag := mutedStyle.Render(" latest") if mod.Pinned() { tag = warnStyle.Render(" pinned " + mod.Version) } rows = append(rows, " "+bulletStyle.Render("◆ ")+mod.Slug+tag) } rows = append(rows, "", mutedStyle.Render(fmt.Sprintf("Total: %d mods", len(cfg.ModrinthMods)))) return lipgloss.JoinVertical(lipgloss.Left, rows...) } func modsSummary(mods []config.Mod) string { if len(mods) == 0 { return "No mods yet — search Modrinth to add some." } var b strings.Builder for _, mod := range mods { fmt.Fprintf(&b, "• %s\n", modLabel(mod)) } return strings.TrimRight(b.String(), "\n") } func modLabel(mod config.Mod) string { if mod.Pinned() { return mod.Slug + " (" + mod.Version + ")" } return mod.Slug + " (latest)" } func versionTag(version string) string { if version == "" { return "latest" } return "pinned " + version } func upsertMod(mods []config.Mod, mod config.Mod) []config.Mod { for i := range mods { if mods[i].Slug == mod.Slug { mods[i] = mod return mods } } return append(mods, mod) } func removeMod(mods []config.Mod, slug string) []config.Mod { kept := make([]config.Mod, 0, len(mods)) for _, mod := range mods { if mod.Slug != slug { kept = append(kept, mod) } } return kept } func selectHeight(n int) int { return min(10, n) + 2 } func containsStr(list []string, s string) bool { for _, v := range list { if v == s { return true } } return false } func pickerTheme() *huh.Theme { theme := huh.ThemeCharm() theme.Focused.Title = theme.Focused.Title.Foreground(colAccent).Bold(true) theme.Focused.Base = theme.Focused.Base.BorderForeground(colPrimary) return theme } func sizeCmd(w, h int) tea.Cmd { return func() tea.Msg { return tea.WindowSizeMsg{Width: w, Height: h} } }