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"4748 optionsMenuActionTheme = "theme"49 optionsMenuActionGuides = "guides"50)5152var mainMenuItems = []ui.MenuItem{53 {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"},54 {Action: mainMenuActionExport, ItemTitle: "Export", Desc: "build a printable puzzle pack"},55 {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"},56 {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"},57 {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"},58}5960var optionsMenuItems = []ui.MenuItem{61 {Action: optionsMenuActionTheme, ItemTitle: "Theme", Desc: "change colors"},62 {Action: optionsMenuActionGuides, ItemTitle: "Guides", Desc: "learn the rules"},63}6465const (66 mainMenuView viewState = iota67 playMenuView68 optionsMenuView69 seedInputView70 createView71 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 create createState196 weekly weeklyState197 help helpState198 stats statsState199 theme themeState200 debug debugState201 notice noticeState202203 spinner spinner.Model204205 width int // available content width (terminal - rootStyle frame)206 height int // available content height (terminal - rootStyle frame)207208 store *store.Store209210 // Config211 cfg *config.Config212 configPath string213214 createRandIndex func(int) int215}216217func newSpinner() spinner.Model {218 p := theme.Current()219 sp := spinner.New()220 sp.Spinner = spinner.Dot221 sp.Style = lipgloss.NewStyle().Foreground(p.Accent)222 return sp223}224225// InitialModel creates the root TUI model for the main menu.226func InitialModel(s *store.Store, cfg *config.Config, configPath string) model {227 r := initDebugRenderer()228 l := ui.InitCategoryList(gameCategoryItems, "Select Category")229 mm := ui.NewMainMenu(mainMenuItems)230 m := model{231 state: mainMenuView,232 screens: make(map[viewState]screenModel),233 debug: debugState{renderer: r},234 nav: navigationState{gameSelectList: l, mainMenu: mm},235 spinner: newSpinner(),236 store: s,237 cfg: cfg,238 configPath: configPath,239 }240 return m.initScreen(mainMenuView)241}242243// InitialModelWithGame creates a model that starts directly in gameView,244// bypassing the menu. Used by CLI flags (--new, --continue).245func InitialModelWithGame(s *store.Store, cfg *config.Config, configPath string, g game.Gamer, activeGameID int64, completionSaved bool) model {246 r := initDebugRenderer()247 l := ui.InitCategoryList(gameCategoryItems, "Select Category")248 mm := ui.NewMainMenu(mainMenuItems)249 m := model{250 state: gameView,251 screens: make(map[viewState]screenModel),252 debug: debugState{renderer: r},253 nav: navigationState{gameSelectList: l, mainMenu: mm},254 spinner: newSpinner(),255 store: s,256 cfg: cfg,257 configPath: configPath,258 session: sessionState{259 game: g,260 activeGameID: activeGameID,261 completionSaved: completionSaved,262 returnState: mainMenuView,263 },264 }265 // Pre-cache the main menu screen so back-navigation from game works.266 return m.initScreen(mainMenuView)267}268269func (m model) Init() tea.Cmd {270 return nil271}272273func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {274 year, week := now.ISOWeek()275 return []ui.MenuItem{276 {Action: playMenuActionCreate, ItemTitle: "Create", Desc: "a new puzzle"},277 {Action: playMenuActionContinue, ItemTitle: "Continue", Desc: "a previously played puzzle"},278 {Action: playMenuActionDaily, ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},279 {Action: playMenuActionWeekly, ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},280 }281}