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/textarea"23 "charm.land/bubbles/v2/textinput"24 "charm.land/bubbles/v2/viewport"25 tea "charm.land/bubbletea/v2"26 "charm.land/lipgloss/v2"27 "github.com/charmbracelet/glamour"28)2930var (31 gameCategoryItems = buildCategoryItems()32 GameCategories = gameCategoryItems33)3435type viewState int3637const (38 mainMenuActionPlay = "play"39 mainMenuActionExport = "export"40 mainMenuActionStats = "stats"41 mainMenuActionOptions = "options"42 mainMenuActionQuit = "quit"4344 playMenuActionCreate = "create"45 playMenuActionContinue = "continue"46 playMenuActionDaily = "daily"47 playMenuActionWeekly = "weekly"48 playMenuActionSeeded = "seeded"4950 optionsMenuActionTheme = "theme"51 optionsMenuActionGuides = "guides"52)5354var mainMenuItems = []ui.MenuItem{55 {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"},56 {Action: mainMenuActionExport, ItemTitle: "Export", Desc: "build a printable puzzle pack"},57 {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"},58 {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"},59 {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"},60}6162var optionsMenuItems = []ui.MenuItem{63 {Action: optionsMenuActionTheme, ItemTitle: "Theme", Desc: "change colors"},64 {Action: optionsMenuActionGuides, ItemTitle: "Guides", Desc: "learn the rules"},65}6667const (68 mainMenuView viewState = iota69 playMenuView70 optionsMenuView71 seedInputView72 gameSelectView73 modeSelectView74 exportView75 generatingView76 exportRunningView77 gameView78 continueView79 weeklyView80 helpSelectView81 helpDetailView82 statsView83 themeSelectView84)8586type navigationState struct {87 mainMenu ui.MainMenu88 playMenu ui.MainMenu89 optionsMenu ui.MainMenu90 gameSelectList list.Model91 categoryDetail viewport.Model92 modeSelectList list.Model93 selectedCategory registry.Entry94 selectedModeTitle string95}9697type continueState struct {98 table table.Model99 games []store.GameRecord100}101102type weeklyState struct {103 table table.Model104 rows []weeklyRow105 cursor time.Time106}107108type seedState struct {109 input textinput.Model110 modeOptions []seedModeOption111 modeIndex int112 focus seedInputFocus113 lastModeKey string114}115116type sessionState struct {117 game game.Gamer118 activeGameID int64119 completionSaved bool120 returnState viewState121 weeklyAdvance *weekly.Info122 generating bool123 spawnJobID int64124 spawnCancel context.CancelFunc125 spawn *spawnRequest126}127128type helpState struct {129 selectList list.Model130 category registry.Entry131 viewport viewport.Model132 renderer *glamour.TermRenderer133 rendererWidth int134 rendererTheme string135 showFull bool136}137138type statsState struct {139 cards []stats.Card140 profile stats.ProfileBanner141 viewport viewport.Model142}143144type themeState struct {145 list list.Model146 previous string147}148149type exportState struct {150 initialized bool151 focus exportFocus152 values exportFormValues153 titleInput textinput.Model154 headerInput textarea.Model155 advertInput textinput.Model156 volumeInput textinput.Model157 seedInput textinput.Model158 pdfPathInput textinput.Model159 jsonlPathInput textinput.Model160 cards []exportGameCard161 cardIndex int162 bucketIndex int163 cardRowOffset int164 running bool165 jobID int64166 cancel context.CancelFunc167}168169type debugState struct {170 enabled bool171 renderer *glamour.TermRenderer172 info string173}174175type spawnSource string176177const (178 spawnSourceNormal spawnSource = "normal"179 spawnSourceDaily spawnSource = "daily"180 spawnSourceSeed spawnSource = "seed"181 spawnSourceWeekly spawnSource = "weekly"182)183184type spawnRequest struct {185 source spawnSource186 name string187 gameType string188 modeTitle string189 run store.RunMetadata190 returnState viewState191 exitState viewState192 weeklyInfo *weekly.Info193}194195type seedInputFocus int196197const (198 seedFocusText seedInputFocus = iota199 seedFocusMode200)201202type seedModeOption struct {203 key string204 label string205 gameType string206}207208type model struct {209 state viewState210211 nav navigationState212 cont continueState213 session sessionState214 seed seedState215 weekly weeklyState216 help helpState217 stats statsState218 theme themeState219 export exportState220 debug debugState221 notice noticeState222223 spinner spinner.Model224225 width int // available content width (terminal - rootStyle frame)226 height int // available content height (terminal - rootStyle frame)227228 store *store.Store229230 // Config231 cfg *config.Config232 configPath string233}234235func newSpinner() spinner.Model {236 p := theme.Current()237 sp := spinner.New()238 sp.Spinner = spinner.Dot239 sp.Style = lipgloss.NewStyle().Foreground(p.Accent)240 return sp241}242243// InitialModel creates the root TUI model for the main menu.244func InitialModel(s *store.Store, cfg *config.Config, configPath string) model {245 r := initDebugRenderer()246 l := ui.InitCategoryList(gameCategoryItems, "Select Category")247 mm := ui.NewMainMenu(mainMenuItems)248 return model{249 state: mainMenuView,250 debug: debugState{renderer: r},251 nav: navigationState{gameSelectList: l, mainMenu: mm},252 spinner: newSpinner(),253 store: s,254 cfg: cfg,255 configPath: configPath,256 }257}258259// InitialModelWithGame creates a model that starts directly in gameView,260// bypassing the menu. Used by CLI flags (--new, --continue).261func InitialModelWithGame(s *store.Store, cfg *config.Config, configPath string, g game.Gamer, activeGameID int64, completionSaved bool) model {262 r := initDebugRenderer()263 l := ui.InitCategoryList(gameCategoryItems, "Select Category")264 mm := ui.NewMainMenu(mainMenuItems)265 return model{266 state: gameView,267 debug: debugState{renderer: r},268 nav: navigationState{gameSelectList: l, mainMenu: mm},269 spinner: newSpinner(),270 store: s,271 cfg: cfg,272 configPath: configPath,273 session: sessionState{274 game: g,275 activeGameID: activeGameID,276 completionSaved: completionSaved,277 returnState: mainMenuView,278 },279 }280}281282func (m model) Init() tea.Cmd {283 return nil284}285286func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {287 year, week := now.ISOWeek()288 return []ui.MenuItem{289 {Action: playMenuActionCreate, ItemTitle: "Create", Desc: "a new puzzle"},290 {Action: playMenuActionContinue, ItemTitle: "Continue", Desc: "a previously played puzzle"},291 {Action: playMenuActionDaily, ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},292 {Action: playMenuActionWeekly, ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},293 {Action: playMenuActionSeeded, ItemTitle: "Seeded", Desc: "enter a specific seed"},294 }295}