hachem.wtf
aboutsummaryrefslogtreecommitdiff
path: root/internal/cli/mods.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/cli/mods.go')
-rw-r--r--internal/cli/mods.go318
1 files changed, 318 insertions, 0 deletions
diff --git a/internal/cli/mods.go b/internal/cli/mods.go
new file mode 100644
index 0000000..b5ff93e
--- /dev/null
+++ b/internal/cli/mods.go
@@ -0,0 +1,318 @@
+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}
+ }
+}