clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

feat/app-decomposition
puzzletea / app / model.go
model.go 7.0K
1// Package app implements the root TUI model for PuzzleTea, including2// the Elm-architecture update/view loop, game spawning, and debug overlay.3package app45import (6 "context"7 "strconv"8 "time"910 "github.com/FelineStateMachine/puzzletea/config"11 "github.com/FelineStateMachine/puzzletea/game"12 "github.com/FelineStateMachine/puzzletea/registry"13 "github.com/FelineStateMachine/puzzletea/stats"14 "github.com/FelineStateMachine/puzzletea/store"15 "github.com/FelineStateMachine/puzzletea/theme"16 "github.com/FelineStateMachine/puzzletea/ui"17 "github.com/FelineStateMachine/puzzletea/weekly"1819 "charm.land/bubbles/v2/list"20 "charm.land/bubbles/v2/spinner"21 "charm.land/bubbles/v2/table"22 "charm.land/bubbles/v2/textinput"23 "charm.land/bubbles/v2/viewport"24 tea "charm.land/bubbletea/v2"25 "charm.land/lipgloss/v2"26 "github.com/charmbracelet/glamour"27)2829var (30 gameCategoryItems = buildCategoryItems()31 GameCategories = gameCategoryItems32)3334type viewState int3536const (37 mainMenuActionPlay = "play"38 mainMenuActionExport = "export"39 mainMenuActionStats = "stats"40 mainMenuActionOptions = "options"41 mainMenuActionQuit = "quit"4243 playMenuActionCreate = "create"44 playMenuActionContinue = "continue"45 playMenuActionDaily = "daily"46 playMenuActionWeekly = "weekly"47 playMenuActionSeeded = "seeded"4849 optionsMenuActionTheme = "theme"50 optionsMenuActionGuides = "guides"51)5253var mainMenuItems = []ui.MenuItem{54 {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"},55 {Action: mainMenuActionExport, ItemTitle: "Export", Desc: "build a printable puzzle pack"},56 {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"},57 {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"},58 {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"},59}6061var optionsMenuItems = []ui.MenuItem{62 {Action: optionsMenuActionTheme, ItemTitle: "Theme", Desc: "change colors"},63 {Action: optionsMenuActionGuides, ItemTitle: "Guides", Desc: "learn the rules"},64}6566const (67 mainMenuView viewState = iota68 playMenuView69 optionsMenuView70 seedInputView71 gameSelectView72 modeSelectView73 exportView74 generatingView75 exportRunningView76 gameView77 continueView78 weeklyView79 helpSelectView80 helpDetailView81 statsView82 themeSelectView83)8485type navigationState struct {86 mainMenu ui.MainMenu87 playMenu ui.MainMenu88 optionsMenu ui.MainMenu89 gameSelectList list.Model90 categoryDetail viewport.Model91 modeSelectList list.Model92 selectedCategory registry.Entry93 selectedModeTitle string94}9596type continueState struct {97 table table.Model98 games []store.GameRecord99}100101type weeklyState struct {102 table table.Model103 rows []weeklyRow104 cursor time.Time105}106107type seedState struct {108 input textinput.Model109 modeOptions []seedModeOption110 modeIndex int111 focus seedInputFocus112 lastModeKey string113}114115type sessionState struct {116 game game.Gamer117 activeGameID int64118 completionSaved bool119 returnState viewState120 weeklyAdvance *weekly.Info121 generating bool122 spawnJobID int64123 spawnCancel context.CancelFunc124 spawn *spawnRequest125}126127type helpState struct {128 selectList list.Model129 category registry.Entry130 viewport viewport.Model131 renderer *glamour.TermRenderer132 rendererWidth int133 rendererTheme string134 showFull bool135}136137type statsState struct {138 cards []stats.Card139 profile stats.ProfileBanner140 viewport viewport.Model141}142143type themeState struct {144 list list.Model145 previous string146}147148type debugState struct {149 enabled bool150 renderer *glamour.TermRenderer151 info string152}153154type spawnSource string155156const (157 spawnSourceNormal spawnSource = "normal"158 spawnSourceDaily spawnSource = "daily"159 spawnSourceSeed spawnSource = "seed"160 spawnSourceWeekly spawnSource = "weekly"161)162163type spawnRequest struct {164 source spawnSource165 name string166 gameType string167 modeTitle string168 run store.RunMetadata169 returnState viewState170 exitState viewState171 weeklyInfo *weekly.Info172}173174type seedInputFocus int175176const (177 seedFocusText seedInputFocus = iota178 seedFocusMode179)180181type seedModeOption struct {182 key string183 label string184 gameType string185}186187type model struct {188 state viewState189 screens map[viewState]screenModel // persistent screen instances190191 nav navigationState192 cont continueState193 session sessionState194 seed seedState195 weekly weeklyState196 help helpState197 stats statsState198 theme themeState199 debug debugState200 notice noticeState201202 spinner spinner.Model203204 width int // available content width (terminal - rootStyle frame)205 height int // available content height (terminal - rootStyle frame)206207 store *store.Store208209 // Config210 cfg *config.Config211 configPath string212}213214func newSpinner() spinner.Model {215 p := theme.Current()216 sp := spinner.New()217 sp.Spinner = spinner.Dot218 sp.Style = lipgloss.NewStyle().Foreground(p.Accent)219 return sp220}221222// InitialModel creates the root TUI model for the main menu.223func InitialModel(s *store.Store, cfg *config.Config, configPath string) model {224 r := initDebugRenderer()225 l := ui.InitCategoryList(gameCategoryItems, "Select Category")226 mm := ui.NewMainMenu(mainMenuItems)227 m := model{228 state: mainMenuView,229 screens: make(map[viewState]screenModel),230 debug: debugState{renderer: r},231 nav: navigationState{gameSelectList: l, mainMenu: mm},232 spinner: newSpinner(),233 store: s,234 cfg: cfg,235 configPath: configPath,236 }237 return m.initScreen(mainMenuView)238}239240// InitialModelWithGame creates a model that starts directly in gameView,241// bypassing the menu. Used by CLI flags (--new, --continue).242func InitialModelWithGame(s *store.Store, cfg *config.Config, configPath string, g game.Gamer, activeGameID int64, completionSaved bool) model {243 r := initDebugRenderer()244 l := ui.InitCategoryList(gameCategoryItems, "Select Category")245 mm := ui.NewMainMenu(mainMenuItems)246 m := model{247 state: gameView,248 screens: make(map[viewState]screenModel),249 debug: debugState{renderer: r},250 nav: navigationState{gameSelectList: l, mainMenu: mm},251 spinner: newSpinner(),252 store: s,253 cfg: cfg,254 configPath: configPath,255 session: sessionState{256 game: g,257 activeGameID: activeGameID,258 completionSaved: completionSaved,259 returnState: mainMenuView,260 },261 }262 // Pre-cache the main menu screen so back-navigation from game works.263 return m.initScreen(mainMenuView)264}265266func (m model) Init() tea.Cmd {267 return nil268}269270func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {271 year, week := now.ISOWeek()272 return []ui.MenuItem{273 {Action: playMenuActionCreate, ItemTitle: "Create", Desc: "a new puzzle"},274 {Action: playMenuActionContinue, ItemTitle: "Continue", Desc: "a previously played puzzle"},275 {Action: playMenuActionDaily, ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},276 {Action: playMenuActionWeekly, ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},277 {Action: playMenuActionSeeded, ItemTitle: "Seeded", Desc: "enter a specific seed"},278 }279}