clone

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

tech debt 9

Dami committed 0e2be93
commit
0e2be937b131e0213f24fdea652e0c29dc54f0f9
parent
1ddddc695440a93c00d061471f1ff95e73421c0a
author
Dami <dami.etoile@gmail.com>
10 files changed, +1036 -898
Mapp/model.go38+-
Aapp/screen_core.go190++++++
Aapp/screen_menu.go152+++++
Aapp/screen_navigation.go309++++++++++
Aapp/screen_support.go256+++++++++
Dapp/screens.go887------------------------------
Aapp/screens_test.go74+++
Mui/mainmenu.go4+
Mui/menuitem.go8+
Mui/ui_test.go16+
M app/model.go +27 -11
@@ -33,16 +33,32 @@ var (
3333 3434 type viewState int3535 36+const (37+ mainMenuActionPlay = "play"38+ mainMenuActionStats = "stats"39+ mainMenuActionOptions = "options"40+ mainMenuActionQuit = "quit"41+42+ playMenuActionCreate = "create"43+ playMenuActionContinue = "continue"44+ playMenuActionDaily = "daily"45+ playMenuActionWeekly = "weekly"46+ playMenuActionSeeded = "seeded"47+48+ optionsMenuActionTheme = "theme"49+ optionsMenuActionGuides = "guides"50+)51+3652 var mainMenuItems = []ui.MenuItem{37- {ItemTitle: "Play", Desc: "start or continue a puzzle"},38- {ItemTitle: "Stats", Desc: "your progress"},39- {ItemTitle: "Options", Desc: "configure and learn"},40- {ItemTitle: "Quit", Desc: "exit puzzletea"},53+ {Action: mainMenuActionPlay, ItemTitle: "Play", Desc: "start or continue a puzzle"},54+ {Action: mainMenuActionStats, ItemTitle: "Stats", Desc: "your progress"},55+ {Action: mainMenuActionOptions, ItemTitle: "Options", Desc: "configure and learn"},56+ {Action: mainMenuActionQuit, ItemTitle: "Quit", Desc: "exit puzzletea"},4157 }4258 4359 var optionsMenuItems = []ui.MenuItem{44- {ItemTitle: "Theme", Desc: "change colors"},45- {ItemTitle: "Guides", Desc: "learn the rules"},60+ {Action: optionsMenuActionTheme, ItemTitle: "Theme", Desc: "change colors"},61+ {Action: optionsMenuActionGuides, ItemTitle: "Guides", Desc: "learn the rules"},4662 }4763 4864 const (
@@ -241,10 +257,10 @@ func (m model) Init() tea.Cmd {
241257 func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {242258 year, week := now.ISOWeek()243259 return []ui.MenuItem{244- {ItemTitle: "Create", Desc: "a new puzzle"},245- {ItemTitle: "Continue", Desc: "a previously played puzzle"},246- {ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},247- {ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},248- {ItemTitle: "Seeded", Desc: "enter a specific seed"},260+ {Action: playMenuActionCreate, ItemTitle: "Create", Desc: "a new puzzle"},261+ {Action: playMenuActionContinue, ItemTitle: "Continue", Desc: "a previously played puzzle"},262+ {Action: playMenuActionDaily, ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},263+ {Action: playMenuActionWeekly, ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},264+ {Action: playMenuActionSeeded, ItemTitle: "Seeded", Desc: "enter a specific seed"},249265 }250266 }
A app/screen_core.go +190 -0
@@ -0,0 +1,190 @@
1+package app2+3+import tea "charm.land/bubbletea/v2"4+5+type screenAction interface{ isScreenAction() }6+7+type openPlayMenuAction struct{}8+9+func (openPlayMenuAction) isScreenAction() {}10+11+type openStatsAction struct{}12+13+func (openStatsAction) isScreenAction() {}14+15+type openOptionsMenuAction struct{}16+17+func (openOptionsMenuAction) isScreenAction() {}18+19+type quitAction struct{}20+21+func (quitAction) isScreenAction() {}22+23+type openGameSelectAction struct{}24+25+func (openGameSelectAction) isScreenAction() {}26+27+type openContinueAction struct{}28+29+func (openContinueAction) isScreenAction() {}30+31+type openDailyAction struct{}32+33+func (openDailyAction) isScreenAction() {}34+35+type openWeeklyAction struct{}36+37+func (openWeeklyAction) isScreenAction() {}38+39+type openSeedInputAction struct{}40+41+func (openSeedInputAction) isScreenAction() {}42+43+type backAction struct {44+ target viewState45+}46+47+func (backAction) isScreenAction() {}48+49+type gameSelectEnterAction struct{}50+51+func (gameSelectEnterAction) isScreenAction() {}52+53+type modeSelectEnterAction struct{}54+55+func (modeSelectEnterAction) isScreenAction() {}56+57+type continueEnterAction struct{}58+59+func (continueEnterAction) isScreenAction() {}60+61+type weeklyShiftAction struct {62+ delta int63+}64+65+func (weeklyShiftAction) isScreenAction() {}66+67+type weeklyEnterAction struct{}68+69+func (weeklyEnterAction) isScreenAction() {}70+71+type helpSelectEnterAction struct{}72+73+func (helpSelectEnterAction) isScreenAction() {}74+75+type openThemeSelectAction struct{}76+77+func (openThemeSelectAction) isScreenAction() {}78+79+type openHelpSelectAction struct{}80+81+func (openHelpSelectAction) isScreenAction() {}82+83+type previewThemeAction struct {84+ name string85+}86+87+func (previewThemeAction) isScreenAction() {}88+89+type confirmThemeAction struct{}90+91+func (confirmThemeAction) isScreenAction() {}92+93+type seedConfirmAction struct{}94+95+func (seedConfirmAction) isScreenAction() {}96+97+type screenModel interface {98+ State() viewState99+ Resize(width, height int) screenModel100+ Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)101+ View(notice noticeState) string102+ Apply(m model) model103+}104+105+func (m model) activeScreen() screenModel {106+ switch m.state {107+ case mainMenuView:108+ return mainMenuScreen{109+ width: m.width,110+ height: m.height,111+ menu: m.nav.mainMenu,112+ }113+ case playMenuView:114+ return playMenuScreen{115+ width: m.width,116+ height: m.height,117+ menu: m.nav.playMenu,118+ }119+ case optionsMenuView:120+ return optionsMenuScreen{121+ width: m.width,122+ height: m.height,123+ menu: m.nav.optionsMenu,124+ }125+ case seedInputView:126+ return seedInputScreen{127+ width: m.width,128+ height: m.height,129+ seed: m.seed,130+ }131+ case gameSelectView:132+ return gameSelectScreen{133+ width: m.width,134+ height: m.height,135+ list: m.nav.gameSelectList,136+ detail: m.nav.categoryDetail,137+ }138+ case modeSelectView:139+ return modeSelectScreen{140+ width: m.width,141+ height: m.height,142+ entry: m.nav.selectedCategory,143+ list: m.nav.modeSelectList,144+ }145+ case continueView:146+ return continueScreen{147+ width: m.width,148+ height: m.height,149+ cont: m.cont,150+ }151+ case weeklyView:152+ return weeklyScreen{153+ width: m.width,154+ height: m.height,155+ weekly: m.weekly,156+ }157+ case helpSelectView:158+ return helpSelectScreen{159+ width: m.width,160+ height: m.height,161+ help: m.help,162+ }163+ case helpDetailView:164+ return helpDetailScreen{165+ width: m.width,166+ height: m.height,167+ help: m.help,168+ }169+ case statsView:170+ return statsScreen{171+ width: m.width,172+ height: m.height,173+ stats: m.stats,174+ }175+ case themeSelectView:176+ return themeSelectScreen{177+ width: m.width,178+ height: m.height,179+ theme: m.theme,180+ }181+ case generatingView:182+ return generatingScreen{183+ width: m.width,184+ height: m.height,185+ spinner: m.spinner,186+ }187+ default:188+ return nil189+ }190+}
A app/screen_menu.go +152 -0
@@ -0,0 +1,152 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/ui"5+6+ "charm.land/bubbles/v2/key"7+ tea "charm.land/bubbletea/v2"8+)9+10+type mainMenuScreen struct {11+ width int12+ height int13+ menu ui.MainMenu14+}15+16+func (s mainMenuScreen) State() viewState { return mainMenuView }17+18+func (s mainMenuScreen) Resize(width, height int) screenModel {19+ s.width = width20+ s.height = height21+ return s22+}23+24+func (s mainMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {25+ updateMainMenuCursor(msg, &s.menu)26+ keyMsg, ok := msg.(tea.KeyPressMsg)27+ if !ok || !key.Matches(keyMsg, rootKeys.Enter) {28+ return s, nil, nil29+ }30+31+ switch s.menu.SelectedAction() {32+ case mainMenuActionPlay:33+ return s, nil, openPlayMenuAction{}34+ case mainMenuActionStats:35+ return s, nil, openStatsAction{}36+ case mainMenuActionOptions:37+ return s, nil, openOptionsMenuAction{}38+ case mainMenuActionQuit:39+ return s, nil, quitAction{}40+ default:41+ return s, nil, nil42+ }43+}44+45+func (s mainMenuScreen) View(notice noticeState) string {46+ return centerContentWithNotice(s.width, s.height, notice, s.menu.View())47+}48+49+func (s mainMenuScreen) Apply(m model) model {50+ m.state = mainMenuView51+ m.nav.mainMenu = s.menu52+ return m53+}54+55+type playMenuScreen struct {56+ width int57+ height int58+ menu ui.MainMenu59+}60+61+func (s playMenuScreen) State() viewState { return playMenuView }62+63+func (s playMenuScreen) Resize(width, height int) screenModel {64+ s.width = width65+ s.height = height66+ return s67+}68+69+func (s playMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {70+ updateMainMenuCursor(msg, &s.menu)71+ keyMsg, ok := msg.(tea.KeyPressMsg)72+ if !ok {73+ return s, nil, nil74+ }75+76+ switch {77+ case key.Matches(keyMsg, rootKeys.Enter):78+ switch s.menu.SelectedAction() {79+ case playMenuActionCreate:80+ return s, nil, openGameSelectAction{}81+ case playMenuActionContinue:82+ return s, nil, openContinueAction{}83+ case playMenuActionDaily:84+ return s, nil, openDailyAction{}85+ case playMenuActionWeekly:86+ return s, nil, openWeeklyAction{}87+ case playMenuActionSeeded:88+ return s, nil, openSeedInputAction{}89+ }90+ case key.Matches(keyMsg, rootKeys.Escape):91+ return s, nil, backAction{target: mainMenuView}92+ }93+94+ return s, nil, nil95+}96+97+func (s playMenuScreen) View(notice noticeState) string {98+ return centerContentWithNotice(s.width, s.height, notice, s.menu.ViewAsPanel("Play"))99+}100+101+func (s playMenuScreen) Apply(m model) model {102+ m.state = playMenuView103+ m.nav.playMenu = s.menu104+ return m105+}106+107+type optionsMenuScreen struct {108+ width int109+ height int110+ menu ui.MainMenu111+}112+113+func (s optionsMenuScreen) State() viewState { return optionsMenuView }114+115+func (s optionsMenuScreen) Resize(width, height int) screenModel {116+ s.width = width117+ s.height = height118+ return s119+}120+121+func (s optionsMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {122+ updateMainMenuCursor(msg, &s.menu)123+ keyMsg, ok := msg.(tea.KeyPressMsg)124+ if !ok {125+ return s, nil, nil126+ }127+128+ switch {129+ case key.Matches(keyMsg, rootKeys.Enter):130+ switch s.menu.SelectedAction() {131+ case optionsMenuActionTheme:132+ return s, nil, openThemeSelectAction{}133+ case optionsMenuActionGuides:134+ return s, nil, openHelpSelectAction{}135+ }136+ case key.Matches(keyMsg, rootKeys.Escape):137+ return s, nil, backAction{target: mainMenuView}138+ }139+140+ return s, nil, nil141+}142+143+func (s optionsMenuScreen) View(notice noticeState) string {144+ items := s.menu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")145+ return renderPanelView(s.width, s.height, notice, "Options", items, "↑/↓ navigate • enter select • esc back")146+}147+148+func (s optionsMenuScreen) Apply(m model) model {149+ m.state = optionsMenuView150+ m.nav.optionsMenu = s.menu151+ return m152+}
A app/screen_navigation.go +309 -0
@@ -0,0 +1,309 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/registry"5+ sessionflow "github.com/FelineStateMachine/puzzletea/session"6+ "github.com/FelineStateMachine/puzzletea/ui"7+8+ "charm.land/bubbles/v2/key"9+ "charm.land/bubbles/v2/list"10+ "charm.land/bubbles/v2/viewport"11+ tea "charm.land/bubbletea/v2"12+)13+14+type seedInputScreen struct {15+ width int16+ height int17+ seed seedState18+}19+20+func (s seedInputScreen) State() viewState { return seedInputView }21+22+func (s seedInputScreen) Resize(width, height int) screenModel {23+ s.width = width24+ s.height = height25+ s.seed.input.SetWidth(min(width, 48))26+ return s27+}28+29+func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {30+ keyMsg, ok := msg.(tea.KeyPressMsg)31+ if ok {32+ switch {33+ case key.Matches(keyMsg, rootKeys.Enter):34+ if sessionflow.NormalizeSeed(s.seed.input.Value()) != "" {35+ return s, nil, seedConfirmAction{}36+ }37+ return s, nil, nil38+ case key.Matches(keyMsg, rootKeys.Escape):39+ return s, nil, backAction{target: playMenuView}40+ }41+ }42+43+ m := model{seed: s.seed}44+ next, cmd := m.handleSeedInputUpdate(msg)45+ s.seed = next.seed46+ return s, cmd, nil47+}48+49+func (s seedInputScreen) View(notice noticeState) string {50+ m := model{51+ width: s.width,52+ height: s.height,53+ seed: s.seed,54+ notice: notice,55+ }56+ return m.renderPanel(57+ "Enter Seed",58+ m.seedInputBody(),59+ "↑/↓ change field • ←/→ game • enter confirm • esc back",60+ )61+}62+63+func (s seedInputScreen) Apply(m model) model {64+ m.state = seedInputView65+ m.seed = s.seed66+ return m67+}68+69+type gameSelectScreen struct {70+ width int71+ height int72+ list list.Model73+ detail viewport.Model74+}75+76+func (s gameSelectScreen) State() viewState { return gameSelectView }77+78+func (s gameSelectScreen) Resize(width, height int) screenModel {79+ m := model{80+ width: width,81+ height: height,82+ nav: navigationState{83+ gameSelectList: s.list,84+ categoryDetail: s.detail,85+ },86+ }87+ metrics := categoryPickerSize(width, height)88+ m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)89+ m = m.updateCategoryDetailViewport()90+ s.width = width91+ s.height = height92+ s.list = m.nav.gameSelectList93+ s.detail = m.nav.categoryDetail94+ return s95+}96+97+func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {98+ keyMsg, ok := msg.(tea.KeyPressMsg)99+ if ok {100+ switch {101+ case s.list.SettingFilter() && s.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter):102+ s.list.ResetFilter()103+ s = s.resizeSelf()104+ return s, nil, nil105+ case s.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):106+ case s.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):107+ case key.Matches(keyMsg, rootKeys.Enter):108+ return s, nil, gameSelectEnterAction{}109+ case key.Matches(keyMsg, rootKeys.Escape):110+ return s, nil, backAction{target: playMenuView}111+ case keyMsg.String() == "pgup":112+ s.detail.PageUp()113+ return s, nil, nil114+ case keyMsg.String() == "pgdown":115+ s.detail.PageDown()116+ return s, nil, nil117+ }118+ }119+120+ prev := selectedCategoryName(s.list.SelectedItem())121+ var cmd tea.Cmd122+ s.list, cmd = s.list.Update(msg)123+ if selectedCategoryName(s.list.SelectedItem()) != prev {124+ s = s.resizeSelf()125+ }126+ return s, cmd, nil127+}128+129+func (s gameSelectScreen) View(notice noticeState) string {130+ m := model{131+ width: s.width,132+ height: s.height,133+ notice: notice,134+ nav: navigationState{135+ gameSelectList: s.list,136+ categoryDetail: s.detail,137+ },138+ }139+ return m.gameSelectViewContent()140+}141+142+func (s gameSelectScreen) Apply(m model) model {143+ m.state = gameSelectView144+ m.nav.gameSelectList = s.list145+ m.nav.categoryDetail = s.detail146+ return m147+}148+149+func (s gameSelectScreen) resizeSelf() gameSelectScreen {150+ return s.Resize(s.width, s.height).(gameSelectScreen)151+}152+153+type modeSelectScreen struct {154+ width int155+ height int156+ entry registry.Entry157+ list list.Model158+}159+160+func (s modeSelectScreen) State() viewState { return modeSelectView }161+162+func (s modeSelectScreen) Resize(width, height int) screenModel {163+ s.width = width164+ s.height = height165+ s.list.SetSize(min(width, 64), min(height, ui.ListHeight(s.list)))166+ return s167+}168+169+func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {170+ keyMsg, ok := msg.(tea.KeyPressMsg)171+ if ok {172+ switch {173+ case key.Matches(keyMsg, rootKeys.Enter):174+ return s, nil, modeSelectEnterAction{}175+ case key.Matches(keyMsg, rootKeys.Escape):176+ return s, nil, backAction{target: gameSelectView}177+ }178+ }179+180+ var cmd tea.Cmd181+ s.list, cmd = s.list.Update(msg)182+ return s, cmd, nil183+}184+185+func (s modeSelectScreen) View(notice noticeState) string {186+ m := model{187+ width: s.width,188+ height: s.height,189+ notice: notice,190+ nav: navigationState{191+ selectedCategory: s.entry,192+ modeSelectList: s.list,193+ },194+ }195+ return m.renderPanel(196+ m.nav.selectedCategory.Definition.Name+" — Select Mode",197+ m.nav.modeSelectList.View(),198+ "↑/↓ navigate • enter select • esc back",199+ )200+}201+202+func (s modeSelectScreen) Apply(m model) model {203+ m.state = modeSelectView204+ m.nav.selectedCategory = s.entry205+ m.nav.modeSelectList = s.list206+ return m207+}208+209+type continueScreen struct {210+ width int211+ height int212+ cont continueState213+}214+215+func (s continueScreen) State() viewState { return continueView }216+217+func (s continueScreen) Resize(width, height int) screenModel {218+ s.width = width219+ s.height = height220+ s.cont.table.SetWidth(min(width, ui.ContinueTableWidth()))221+ visibleRows := min(len(s.cont.games), ui.MaxTableRows)222+ s.cont.table.SetHeight(min(height, visibleRows))223+ return s224+}225+226+func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {227+ keyMsg, ok := msg.(tea.KeyPressMsg)228+ if ok {229+ switch {230+ case key.Matches(keyMsg, rootKeys.Enter):231+ return s, nil, continueEnterAction{}232+ case key.Matches(keyMsg, rootKeys.Escape):233+ return s, nil, backAction{target: playMenuView}234+ }235+ }236+237+ var cmd tea.Cmd238+ s.cont.table, cmd = s.cont.table.Update(msg)239+ return s, cmd, nil240+}241+242+func (s continueScreen) View(notice noticeState) string {243+ m := model{244+ width: s.width,245+ height: s.height,246+ notice: notice,247+ cont: s.cont,248+ }249+ return m.renderContinueView()250+}251+252+func (s continueScreen) Apply(m model) model {253+ m.state = continueView254+ m.cont = s.cont255+ return m256+}257+258+type weeklyScreen struct {259+ width int260+ height int261+ weekly weeklyState262+}263+264+func (s weeklyScreen) State() viewState { return weeklyView }265+266+func (s weeklyScreen) Resize(width, height int) screenModel {267+ s.width = width268+ s.height = height269+ s.weekly.table.SetWidth(min(width, ui.WeeklyTableWidth()))270+ visibleRows := min(len(s.weekly.rows), ui.MaxTableRows)271+ s.weekly.table.SetHeight(min(height, visibleRows))272+ return s273+}274+275+func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {276+ keyMsg, ok := msg.(tea.KeyPressMsg)277+ if ok {278+ switch {279+ case key.Matches(keyMsg, rootKeys.Escape):280+ return s, nil, backAction{target: playMenuView}281+ case key.Matches(keyMsg, rootKeys.Enter):282+ return s, nil, weeklyEnterAction{}283+ case keyMsg.String() == "left" || keyMsg.String() == "h":284+ return s, nil, weeklyShiftAction{delta: -1}285+ case keyMsg.String() == "right" || keyMsg.String() == "l":286+ return s, nil, weeklyShiftAction{delta: 1}287+ }288+ }289+290+ var cmd tea.Cmd291+ s.weekly.table, cmd = s.weekly.table.Update(msg)292+ return s, cmd, nil293+}294+295+func (s weeklyScreen) View(notice noticeState) string {296+ m := model{297+ width: s.width,298+ height: s.height,299+ notice: notice,300+ weekly: s.weekly,301+ }302+ return centerContentWithNotice(s.width, s.height, notice, m.weeklyViewContent())303+}304+305+func (s weeklyScreen) Apply(m model) model {306+ m.state = weeklyView307+ m.weekly = s.weekly308+ return m309+}
A app/screen_support.go +256 -0
@@ -0,0 +1,256 @@
1+package app2+3+import (4+ "github.com/FelineStateMachine/puzzletea/theme"5+ "github.com/FelineStateMachine/puzzletea/ui"6+7+ "charm.land/bubbles/v2/key"8+ "charm.land/bubbles/v2/list"9+ "charm.land/bubbles/v2/spinner"10+ tea "charm.land/bubbletea/v2"11+)12+13+type helpSelectScreen struct {14+ width int15+ height int16+ help helpState17+}18+19+func (s helpSelectScreen) State() viewState { return helpSelectView }20+21+func (s helpSelectScreen) Resize(width, height int) screenModel {22+ s.width = width23+ s.height = height24+ listWidth, listHeight := helpSelectListSize(width, height, s.help.selectList)25+ s.help.selectList.SetSize(listWidth, listHeight)26+ return s27+}28+29+func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {30+ keyMsg, ok := msg.(tea.KeyPressMsg)31+ if ok {32+ switch {33+ case key.Matches(keyMsg, rootKeys.Enter):34+ return s, nil, helpSelectEnterAction{}35+ case key.Matches(keyMsg, rootKeys.Escape):36+ return s, nil, backAction{target: optionsMenuView}37+ }38+ }39+40+ var cmd tea.Cmd41+ s.help.selectList, cmd = s.help.selectList.Update(msg)42+ return s, cmd, nil43+}44+45+func (s helpSelectScreen) View(notice noticeState) string {46+ m := model{47+ width: s.width,48+ height: s.height,49+ notice: notice,50+ help: s.help,51+ }52+ return m.renderPanel(53+ "How to Play",54+ m.help.selectList.View(),55+ "↑/↓ navigate • enter select • esc back",56+ )57+}58+59+func (s helpSelectScreen) Apply(m model) model {60+ m.state = helpSelectView61+ m.help.selectList = s.help.selectList62+ return m63+}64+65+type helpDetailScreen struct {66+ width int67+ height int68+ help helpState69+}70+71+func (s helpDetailScreen) State() viewState { return helpDetailView }72+73+func (s helpDetailScreen) Resize(width, height int) screenModel {74+ m := model{75+ width: width,76+ height: height,77+ help: s.help,78+ }79+ m = m.updateHelpDetailViewport()80+ s.width = width81+ s.height = height82+ s.help = m.help83+ return s84+}85+86+func (s helpDetailScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {87+ keyMsg, ok := msg.(tea.KeyPressMsg)88+ if ok && key.Matches(keyMsg, rootKeys.Escape) {89+ return s, nil, backAction{target: helpSelectView}90+ }91+92+ var cmd tea.Cmd93+ s.help.viewport, cmd = s.help.viewport.Update(msg)94+ return s, cmd, nil95+}96+97+func (s helpDetailScreen) View(notice noticeState) string {98+ m := model{99+ width: s.width,100+ height: s.height,101+ notice: notice,102+ help: s.help,103+ }104+ return m.renderPanel(105+ m.help.category.Definition.Name+" — Guide",106+ m.help.viewport.View(),107+ "↑/↓ scroll • esc back",108+ )109+}110+111+func (s helpDetailScreen) Apply(m model) model {112+ m.state = helpDetailView113+ m.help = s.help114+ return m115+}116+117+type statsScreen struct {118+ width int119+ height int120+ stats statsState121+}122+123+func (s statsScreen) State() viewState { return statsView }124+125+func (s statsScreen) Resize(width, height int) screenModel {126+ m := model{127+ width: width,128+ height: height,129+ stats: s.stats,130+ }131+ m = m.updateStatsViewport()132+ s.width = width133+ s.height = height134+ s.stats = m.stats135+ return s136+}137+138+func (s statsScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {139+ keyMsg, ok := msg.(tea.KeyPressMsg)140+ if ok && key.Matches(keyMsg, rootKeys.Escape) {141+ return s, nil, backAction{target: mainMenuView}142+ }143+144+ var cmd tea.Cmd145+ s.stats.viewport, cmd = s.stats.viewport.Update(msg)146+ return s, cmd, nil147+}148+149+func (s statsScreen) View(notice noticeState) string {150+ m := model{151+ width: s.width,152+ height: s.height,153+ notice: notice,154+ stats: s.stats,155+ }156+ return m.renderStatsView()157+}158+159+func (s statsScreen) Apply(m model) model {160+ m.state = statsView161+ m.stats = s.stats162+ return m163+}164+165+type themeSelectScreen struct {166+ width int167+ height int168+ theme themeState169+}170+171+func (s themeSelectScreen) State() viewState { return themeSelectView }172+173+func (s themeSelectScreen) Resize(width, height int) screenModel {174+ const maxVisibleItems = 8175+ menuW := min(width, 64)176+ listW := min(menuW, theme.MaxNameLen+4)177+ s.width = width178+ s.height = height179+ s.theme.list.SetSize(listW, min(height, maxVisibleItems*3))180+ return s181+}182+183+func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {184+ keyMsg, ok := msg.(tea.KeyPressMsg)185+ if ok {186+ switch {187+ case s.theme.list.SettingFilter() && s.theme.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter):188+ s.theme.list.ResetFilter()189+ return s, nil, nil190+ case s.theme.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):191+ case s.theme.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):192+ case key.Matches(keyMsg, rootKeys.Enter):193+ return s, nil, confirmThemeAction{}194+ case key.Matches(keyMsg, rootKeys.Escape):195+ return s, nil, backAction{target: optionsMenuView}196+ }197+ }198+199+ prev := s.theme.list.Index()200+ var cmd tea.Cmd201+ s.theme.list, cmd = s.theme.list.Update(msg)202+ if s.theme.list.Index() != prev {203+ if item, ok := s.theme.list.SelectedItem().(ui.MenuItem); ok {204+ return s, cmd, previewThemeAction{name: item.ItemTitle}205+ }206+ }207+ return s, cmd, nil208+}209+210+func (s themeSelectScreen) View(notice noticeState) string {211+ m := model{212+ width: s.width,213+ height: s.height,214+ notice: notice,215+ theme: s.theme,216+ }217+ return m.themeSelectViewContent()218+}219+220+func (s themeSelectScreen) Apply(m model) model {221+ m.state = themeSelectView222+ m.theme = s.theme223+ return m224+}225+226+type generatingScreen struct {227+ width int228+ height int229+ spinner spinner.Model230+}231+232+func (s generatingScreen) State() viewState { return generatingView }233+234+func (s generatingScreen) Resize(width, height int) screenModel {235+ s.width = width236+ s.height = height237+ return s238+}239+240+func (s generatingScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {241+ var cmd tea.Cmd242+ s.spinner, cmd = s.spinner.Update(msg)243+ return s, cmd, nil244+}245+246+func (s generatingScreen) View(notice noticeState) string {247+ content := s.spinner.View() + " Generating puzzle..."248+ box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content))249+ return ui.CenterView(s.width, s.height, box)250+}251+252+func (s generatingScreen) Apply(m model) model {253+ m.state = generatingView254+ m.spinner = s.spinner255+ return m256+}
D app/screens.go +0 -887
@@ -1,887 +0,0 @@
1-package app2-3-import (4- "github.com/FelineStateMachine/puzzletea/registry"5- sessionflow "github.com/FelineStateMachine/puzzletea/session"6- "github.com/FelineStateMachine/puzzletea/theme"7- "github.com/FelineStateMachine/puzzletea/ui"8-9- "charm.land/bubbles/v2/key"10- "charm.land/bubbles/v2/list"11- "charm.land/bubbles/v2/spinner"12- "charm.land/bubbles/v2/viewport"13- tea "charm.land/bubbletea/v2"14-)15-16-type screenAction interface{ isScreenAction() }17-18-type openPlayMenuAction struct{}19-20-func (openPlayMenuAction) isScreenAction() {}21-22-type openStatsAction struct{}23-24-func (openStatsAction) isScreenAction() {}25-26-type openOptionsMenuAction struct{}27-28-func (openOptionsMenuAction) isScreenAction() {}29-30-type quitAction struct{}31-32-func (quitAction) isScreenAction() {}33-34-type openGameSelectAction struct{}35-36-func (openGameSelectAction) isScreenAction() {}37-38-type openContinueAction struct{}39-40-func (openContinueAction) isScreenAction() {}41-42-type openDailyAction struct{}43-44-func (openDailyAction) isScreenAction() {}45-46-type openWeeklyAction struct{}47-48-func (openWeeklyAction) isScreenAction() {}49-50-type openSeedInputAction struct{}51-52-func (openSeedInputAction) isScreenAction() {}53-54-type backAction struct {55- target viewState56-}57-58-func (backAction) isScreenAction() {}59-60-type gameSelectEnterAction struct{}61-62-func (gameSelectEnterAction) isScreenAction() {}63-64-type modeSelectEnterAction struct{}65-66-func (modeSelectEnterAction) isScreenAction() {}67-68-type continueEnterAction struct{}69-70-func (continueEnterAction) isScreenAction() {}71-72-type weeklyShiftAction struct {73- delta int74-}75-76-func (weeklyShiftAction) isScreenAction() {}77-78-type weeklyEnterAction struct{}79-80-func (weeklyEnterAction) isScreenAction() {}81-82-type helpSelectEnterAction struct{}83-84-func (helpSelectEnterAction) isScreenAction() {}85-86-type openThemeSelectAction struct{}87-88-func (openThemeSelectAction) isScreenAction() {}89-90-type openHelpSelectAction struct{}91-92-func (openHelpSelectAction) isScreenAction() {}93-94-type previewThemeAction struct {95- name string96-}97-98-func (previewThemeAction) isScreenAction() {}99-100-type confirmThemeAction struct{}101-102-func (confirmThemeAction) isScreenAction() {}103-104-type seedConfirmAction struct{}105-106-func (seedConfirmAction) isScreenAction() {}107-108-type screenModel interface {109- State() viewState110- Resize(width, height int) screenModel111- Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)112- View(notice noticeState) string113- Apply(m model) model114-}115-116-type mainMenuScreen struct {117- width int118- height int119- menu ui.MainMenu120-}121-122-func (s mainMenuScreen) State() viewState { return mainMenuView }123-124-func (s mainMenuScreen) Resize(width, height int) screenModel {125- s.width = width126- s.height = height127- return s128-}129-130-func (s mainMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {131- updateMainMenuCursor(msg, &s.menu)132- keyMsg, ok := msg.(tea.KeyPressMsg)133- if !ok {134- return s, nil, nil135- }136- if key.Matches(keyMsg, rootKeys.Enter) {137- switch s.menu.Selected().Title() {138- case "Play":139- return s, nil, openPlayMenuAction{}140- case "Stats":141- return s, nil, openStatsAction{}142- case "Options":143- return s, nil, openOptionsMenuAction{}144- case "Quit":145- return s, nil, quitAction{}146- }147- }148- return s, nil, nil149-}150-151-func (s mainMenuScreen) View(notice noticeState) string {152- return centerContentWithNotice(s.width, s.height, notice, s.menu.View())153-}154-155-func (s mainMenuScreen) Apply(m model) model {156- m.state = mainMenuView157- m.nav.mainMenu = s.menu158- return m159-}160-161-type playMenuScreen struct {162- width int163- height int164- menu ui.MainMenu165-}166-167-func (s playMenuScreen) State() viewState { return playMenuView }168-169-func (s playMenuScreen) Resize(width, height int) screenModel {170- s.width = width171- s.height = height172- return s173-}174-175-func (s playMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {176- updateMainMenuCursor(msg, &s.menu)177- keyMsg, ok := msg.(tea.KeyPressMsg)178- if !ok {179- return s, nil, nil180- }181- switch {182- case key.Matches(keyMsg, rootKeys.Enter):183- switch s.menu.Selected().Title() {184- case "Create":185- return s, nil, openGameSelectAction{}186- case "Continue":187- return s, nil, openContinueAction{}188- case "Daily":189- return s, nil, openDailyAction{}190- case "Weekly":191- return s, nil, openWeeklyAction{}192- case "Seeded":193- return s, nil, openSeedInputAction{}194- }195- case key.Matches(keyMsg, rootKeys.Escape):196- return s, nil, backAction{target: mainMenuView}197- }198- return s, nil, nil199-}200-201-func (s playMenuScreen) View(notice noticeState) string {202- return centerContentWithNotice(s.width, s.height, notice, s.menu.ViewAsPanel("Play"))203-}204-205-func (s playMenuScreen) Apply(m model) model {206- m.state = playMenuView207- m.nav.playMenu = s.menu208- return m209-}210-211-type optionsMenuScreen struct {212- width int213- height int214- menu ui.MainMenu215-}216-217-func (s optionsMenuScreen) State() viewState { return optionsMenuView }218-219-func (s optionsMenuScreen) Resize(width, height int) screenModel {220- s.width = width221- s.height = height222- return s223-}224-225-func (s optionsMenuScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {226- updateMainMenuCursor(msg, &s.menu)227- keyMsg, ok := msg.(tea.KeyPressMsg)228- if !ok {229- return s, nil, nil230- }231- switch {232- case key.Matches(keyMsg, rootKeys.Enter):233- switch s.menu.Selected().Title() {234- case "Theme":235- return s, nil, openThemeSelectAction{}236- case "Guides":237- return s, nil, openHelpSelectAction{}238- }239- case key.Matches(keyMsg, rootKeys.Escape):240- return s, nil, backAction{target: mainMenuView}241- }242- return s, nil, nil243-}244-245-func (s optionsMenuScreen) View(notice noticeState) string {246- items := s.menu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")247- return renderPanelView(s.width, s.height, notice, "Options", items, "↑/↓ navigate • enter select • esc back")248-}249-250-func (s optionsMenuScreen) Apply(m model) model {251- m.state = optionsMenuView252- m.nav.optionsMenu = s.menu253- return m254-}255-256-type seedInputScreen struct {257- width int258- height int259- seed seedState260-}261-262-func (s seedInputScreen) State() viewState { return seedInputView }263-264-func (s seedInputScreen) Resize(width, height int) screenModel {265- s.width = width266- s.height = height267- s.seed.input.SetWidth(min(width, 48))268- return s269-}270-271-func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {272- keyMsg, ok := msg.(tea.KeyPressMsg)273- if ok {274- switch {275- case key.Matches(keyMsg, rootKeys.Enter):276- if normalizeSeedValue(s.seed.input.Value()) != "" {277- return s, nil, seedConfirmAction{}278- }279- return s, nil, nil280- case key.Matches(keyMsg, rootKeys.Escape):281- return s, nil, backAction{target: playMenuView}282- }283- }284-285- m := model{seed: s.seed}286- next, cmd := m.handleSeedInputUpdate(msg)287- s.seed = next.seed288- return s, cmd, nil289-}290-291-func (s seedInputScreen) View(notice noticeState) string {292- m := model{293- width: s.width,294- height: s.height,295- seed: s.seed,296- notice: notice,297- }298- return m.renderPanel(299- "Enter Seed",300- m.seedInputBody(),301- "↑/↓ change field • ←/→ game • enter confirm • esc back",302- )303-}304-305-func (s seedInputScreen) Apply(m model) model {306- m.state = seedInputView307- m.seed = s.seed308- return m309-}310-311-type gameSelectScreen struct {312- width int313- height int314- list list.Model315- detail viewport.Model316-}317-318-func (s gameSelectScreen) State() viewState { return gameSelectView }319-320-func (s gameSelectScreen) Resize(width, height int) screenModel {321- m := model{322- width: width,323- height: height,324- nav: navigationState{325- gameSelectList: s.list,326- categoryDetail: s.detail,327- },328- }329- metrics := categoryPickerSize(width, height)330- m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)331- m = m.updateCategoryDetailViewport()332- s.width = width333- s.height = height334- s.list = m.nav.gameSelectList335- s.detail = m.nav.categoryDetail336- return s337-}338-339-func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {340- keyMsg, ok := msg.(tea.KeyPressMsg)341- if ok {342- switch {343- case s.list.SettingFilter() && s.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter):344- s.list.ResetFilter()345- s = s.resizeSelf()346- return s, nil, nil347- case s.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):348- case s.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):349- case key.Matches(keyMsg, rootKeys.Enter):350- return s, nil, gameSelectEnterAction{}351- case key.Matches(keyMsg, rootKeys.Escape):352- return s, nil, backAction{target: playMenuView}353- case keyMsg.String() == "pgup":354- s.detail.PageUp()355- return s, nil, nil356- case keyMsg.String() == "pgdown":357- s.detail.PageDown()358- return s, nil, nil359- }360- }361-362- prev := selectedCategoryName(s.list.SelectedItem())363- var cmd tea.Cmd364- s.list, cmd = s.list.Update(msg)365- if selectedCategoryName(s.list.SelectedItem()) != prev {366- s = s.resizeSelf()367- }368- return s, cmd, nil369-}370-371-func (s gameSelectScreen) View(notice noticeState) string {372- m := model{373- width: s.width,374- height: s.height,375- notice: notice,376- nav: navigationState{377- gameSelectList: s.list,378- categoryDetail: s.detail,379- },380- }381- return m.gameSelectViewContent()382-}383-384-func (s gameSelectScreen) Apply(m model) model {385- m.state = gameSelectView386- m.nav.gameSelectList = s.list387- m.nav.categoryDetail = s.detail388- return m389-}390-391-func (s gameSelectScreen) resizeSelf() gameSelectScreen {392- return s.Resize(s.width, s.height).(gameSelectScreen)393-}394-395-type modeSelectScreen struct {396- width int397- height int398- entry registry.Entry399- list list.Model400-}401-402-func (s modeSelectScreen) State() viewState { return modeSelectView }403-404-func (s modeSelectScreen) Resize(width, height int) screenModel {405- s.width = width406- s.height = height407- s.list.SetSize(min(width, 64), min(height, ui.ListHeight(s.list)))408- return s409-}410-411-func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {412- keyMsg, ok := msg.(tea.KeyPressMsg)413- if ok {414- switch {415- case key.Matches(keyMsg, rootKeys.Enter):416- return s, nil, modeSelectEnterAction{}417- case key.Matches(keyMsg, rootKeys.Escape):418- return s, nil, backAction{target: gameSelectView}419- }420- }421-422- var cmd tea.Cmd423- s.list, cmd = s.list.Update(msg)424- return s, cmd, nil425-}426-427-func (s modeSelectScreen) View(notice noticeState) string {428- m := model{429- width: s.width,430- height: s.height,431- notice: notice,432- nav: navigationState{433- selectedCategory: s.entry,434- modeSelectList: s.list,435- },436- }437- return m.renderPanel(438- m.nav.selectedCategory.Definition.Name+" — Select Mode",439- m.nav.modeSelectList.View(),440- "↑/↓ navigate • enter select • esc back",441- )442-}443-444-func (s modeSelectScreen) Apply(m model) model {445- m.state = modeSelectView446- m.nav.selectedCategory = s.entry447- m.nav.modeSelectList = s.list448- return m449-}450-451-type continueScreen struct {452- width int453- height int454- cont continueState455-}456-457-func (s continueScreen) State() viewState { return continueView }458-459-func (s continueScreen) Resize(width, height int) screenModel {460- s.width = width461- s.height = height462- s.cont.table.SetWidth(min(width, ui.ContinueTableWidth()))463- visibleRows := min(len(s.cont.games), ui.MaxTableRows)464- s.cont.table.SetHeight(min(height, visibleRows))465- return s466-}467-468-func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {469- keyMsg, ok := msg.(tea.KeyPressMsg)470- if ok {471- switch {472- case key.Matches(keyMsg, rootKeys.Enter):473- return s, nil, continueEnterAction{}474- case key.Matches(keyMsg, rootKeys.Escape):475- return s, nil, backAction{target: playMenuView}476- }477- }478-479- var cmd tea.Cmd480- s.cont.table, cmd = s.cont.table.Update(msg)481- return s, cmd, nil482-}483-484-func (s continueScreen) View(notice noticeState) string {485- m := model{486- width: s.width,487- height: s.height,488- notice: notice,489- cont: s.cont,490- }491- return m.renderContinueView()492-}493-494-func (s continueScreen) Apply(m model) model {495- m.state = continueView496- m.cont = s.cont497- return m498-}499-500-type weeklyScreen struct {501- width int502- height int503- weekly weeklyState504-}505-506-func (s weeklyScreen) State() viewState { return weeklyView }507-508-func (s weeklyScreen) Resize(width, height int) screenModel {509- s.width = width510- s.height = height511- s.weekly.table.SetWidth(min(width, ui.WeeklyTableWidth()))512- visibleRows := min(len(s.weekly.rows), ui.MaxTableRows)513- s.weekly.table.SetHeight(min(height, visibleRows))514- return s515-}516-517-func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {518- keyMsg, ok := msg.(tea.KeyPressMsg)519- if ok {520- switch {521- case key.Matches(keyMsg, rootKeys.Escape):522- return s, nil, backAction{target: playMenuView}523- case key.Matches(keyMsg, rootKeys.Enter):524- return s, nil, weeklyEnterAction{}525- case keyMsg.String() == "left" || keyMsg.String() == "h":526- return s, nil, weeklyShiftAction{delta: -1}527- case keyMsg.String() == "right" || keyMsg.String() == "l":528- return s, nil, weeklyShiftAction{delta: 1}529- }530- }531-532- var cmd tea.Cmd533- s.weekly.table, cmd = s.weekly.table.Update(msg)534- return s, cmd, nil535-}536-537-func (s weeklyScreen) View(notice noticeState) string {538- m := model{539- width: s.width,540- height: s.height,541- notice: notice,542- weekly: s.weekly,543- }544- return centerContentWithNotice(s.width, s.height, notice, m.weeklyViewContent())545-}546-547-func (s weeklyScreen) Apply(m model) model {548- m.state = weeklyView549- m.weekly = s.weekly550- return m551-}552-553-type helpSelectScreen struct {554- width int555- height int556- help helpState557-}558-559-func (s helpSelectScreen) State() viewState { return helpSelectView }560-561-func (s helpSelectScreen) Resize(width, height int) screenModel {562- s.width = width563- s.height = height564- listWidth, listHeight := helpSelectListSize(width, height, s.help.selectList)565- s.help.selectList.SetSize(listWidth, listHeight)566- return s567-}568-569-func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {570- keyMsg, ok := msg.(tea.KeyPressMsg)571- if ok {572- switch {573- case key.Matches(keyMsg, rootKeys.Enter):574- return s, nil, helpSelectEnterAction{}575- case key.Matches(keyMsg, rootKeys.Escape):576- return s, nil, backAction{target: optionsMenuView}577- }578- }579-580- var cmd tea.Cmd581- s.help.selectList, cmd = s.help.selectList.Update(msg)582- return s, cmd, nil583-}584-585-func (s helpSelectScreen) View(notice noticeState) string {586- m := model{587- width: s.width,588- height: s.height,589- notice: notice,590- help: s.help,591- }592- return m.renderPanel(593- "How to Play",594- m.help.selectList.View(),595- "↑/↓ navigate • enter select • esc back",596- )597-}598-599-func (s helpSelectScreen) Apply(m model) model {600- m.state = helpSelectView601- m.help.selectList = s.help.selectList602- return m603-}604-605-type helpDetailScreen struct {606- width int607- height int608- help helpState609-}610-611-func (s helpDetailScreen) State() viewState { return helpDetailView }612-613-func (s helpDetailScreen) Resize(width, height int) screenModel {614- m := model{615- width: width,616- height: height,617- help: s.help,618- }619- m = m.updateHelpDetailViewport()620- s.width = width621- s.height = height622- s.help = m.help623- return s624-}625-626-func (s helpDetailScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {627- keyMsg, ok := msg.(tea.KeyPressMsg)628- if ok && key.Matches(keyMsg, rootKeys.Escape) {629- return s, nil, backAction{target: helpSelectView}630- }631-632- var cmd tea.Cmd633- s.help.viewport, cmd = s.help.viewport.Update(msg)634- return s, cmd, nil635-}636-637-func (s helpDetailScreen) View(notice noticeState) string {638- m := model{639- width: s.width,640- height: s.height,641- notice: notice,642- help: s.help,643- }644- return m.renderPanel(645- m.help.category.Definition.Name+" — Guide",646- m.help.viewport.View(),647- "↑/↓ scroll • esc back",648- )649-}650-651-func (s helpDetailScreen) Apply(m model) model {652- m.state = helpDetailView653- m.help = s.help654- return m655-}656-657-type statsScreen struct {658- width int659- height int660- stats statsState661-}662-663-func (s statsScreen) State() viewState { return statsView }664-665-func (s statsScreen) Resize(width, height int) screenModel {666- m := model{667- width: width,668- height: height,669- stats: s.stats,670- }671- m = m.updateStatsViewport()672- s.width = width673- s.height = height674- s.stats = m.stats675- return s676-}677-678-func (s statsScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {679- keyMsg, ok := msg.(tea.KeyPressMsg)680- if ok && key.Matches(keyMsg, rootKeys.Escape) {681- return s, nil, backAction{target: mainMenuView}682- }683-684- var cmd tea.Cmd685- s.stats.viewport, cmd = s.stats.viewport.Update(msg)686- return s, cmd, nil687-}688-689-func (s statsScreen) View(notice noticeState) string {690- m := model{691- width: s.width,692- height: s.height,693- notice: notice,694- stats: s.stats,695- }696- return m.renderStatsView()697-}698-699-func (s statsScreen) Apply(m model) model {700- m.state = statsView701- m.stats = s.stats702- return m703-}704-705-type themeSelectScreen struct {706- width int707- height int708- theme themeState709-}710-711-func (s themeSelectScreen) State() viewState { return themeSelectView }712-713-func (s themeSelectScreen) Resize(width, height int) screenModel {714- const maxVisibleItems = 8715- menuW := min(width, 64)716- listW := min(menuW, theme.MaxNameLen+4)717- s.width = width718- s.height = height719- s.theme.list.SetSize(listW, min(height, maxVisibleItems*3))720- return s721-}722-723-func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {724- keyMsg, ok := msg.(tea.KeyPressMsg)725- if ok {726- switch {727- case s.theme.list.SettingFilter() && s.theme.list.FilterValue() == "" && key.Matches(keyMsg, rootKeys.Enter):728- s.theme.list.ResetFilter()729- return s, nil, nil730- case s.theme.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):731- case s.theme.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):732- case key.Matches(keyMsg, rootKeys.Enter):733- return s, nil, confirmThemeAction{}734- case key.Matches(keyMsg, rootKeys.Escape):735- return s, nil, backAction{target: optionsMenuView}736- }737- }738-739- prev := s.theme.list.Index()740- var cmd tea.Cmd741- s.theme.list, cmd = s.theme.list.Update(msg)742- if s.theme.list.Index() != prev {743- if item, ok := s.theme.list.SelectedItem().(ui.MenuItem); ok {744- return s, cmd, previewThemeAction{name: item.ItemTitle}745- }746- }747- return s, cmd, nil748-}749-750-func (s themeSelectScreen) View(notice noticeState) string {751- m := model{752- width: s.width,753- height: s.height,754- notice: notice,755- theme: s.theme,756- }757- return m.themeSelectViewContent()758-}759-760-func (s themeSelectScreen) Apply(m model) model {761- m.state = themeSelectView762- m.theme = s.theme763- return m764-}765-766-type generatingScreen struct {767- width int768- height int769- spinner spinner.Model770-}771-772-func (s generatingScreen) State() viewState { return generatingView }773-774-func (s generatingScreen) Resize(width, height int) screenModel {775- s.width = width776- s.height = height777- return s778-}779-780-func (s generatingScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {781- var cmd tea.Cmd782- s.spinner, cmd = s.spinner.Update(msg)783- return s, cmd, nil784-}785-786-func (s generatingScreen) View(notice noticeState) string {787- content := s.spinner.View() + " Generating puzzle..."788- box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content))789- return ui.CenterView(s.width, s.height, box)790-}791-792-func (s generatingScreen) Apply(m model) model {793- m.state = generatingView794- m.spinner = s.spinner795- return m796-}797-798-func normalizeSeedValue(input string) string {799- return sessionflow.NormalizeSeed(input)800-}801-802-func (m model) activeScreen() screenModel {803- switch m.state {804- case mainMenuView:805- return mainMenuScreen{806- width: m.width,807- height: m.height,808- menu: m.nav.mainMenu,809- }810- case playMenuView:811- return playMenuScreen{812- width: m.width,813- height: m.height,814- menu: m.nav.playMenu,815- }816- case optionsMenuView:817- return optionsMenuScreen{818- width: m.width,819- height: m.height,820- menu: m.nav.optionsMenu,821- }822- case seedInputView:823- return seedInputScreen{824- width: m.width,825- height: m.height,826- seed: m.seed,827- }828- case gameSelectView:829- return gameSelectScreen{830- width: m.width,831- height: m.height,832- list: m.nav.gameSelectList,833- detail: m.nav.categoryDetail,834- }835- case modeSelectView:836- return modeSelectScreen{837- width: m.width,838- height: m.height,839- entry: m.nav.selectedCategory,840- list: m.nav.modeSelectList,841- }842- case continueView:843- return continueScreen{844- width: m.width,845- height: m.height,846- cont: m.cont,847- }848- case weeklyView:849- return weeklyScreen{850- width: m.width,851- height: m.height,852- weekly: m.weekly,853- }854- case helpSelectView:855- return helpSelectScreen{856- width: m.width,857- height: m.height,858- help: m.help,859- }860- case helpDetailView:861- return helpDetailScreen{862- width: m.width,863- height: m.height,864- help: m.help,865- }866- case statsView:867- return statsScreen{868- width: m.width,869- height: m.height,870- stats: m.stats,871- }872- case themeSelectView:873- return themeSelectScreen{874- width: m.width,875- height: m.height,876- theme: m.theme,877- }878- case generatingView:879- return generatingScreen{880- width: m.width,881- height: m.height,882- spinner: m.spinner,883- }884- default:885- return nil886- }887-}
A app/screens_test.go +74 -0
@@ -0,0 +1,74 @@
1+package app2+3+import (4+ "testing"5+6+ tea "charm.land/bubbletea/v2"7+ "github.com/FelineStateMachine/puzzletea/ui"8+)9+10+func TestMainMenuScreenRoutesByStableAction(t *testing.T) {11+ screen := mainMenuScreen{12+ menu: ui.NewMainMenu([]ui.MenuItem{13+ {Action: mainMenuActionPlay, ItemTitle: "Launch", Desc: "custom label"},14+ }),15+ }16+17+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})18+ if _, ok := action.(openPlayMenuAction); !ok {19+ t.Fatalf("action = %T, want %T", action, openPlayMenuAction{})20+ }21+}22+23+func TestPlayMenuScreenRoutesByStableAction(t *testing.T) {24+ tests := []struct {25+ name string26+ item ui.MenuItem27+ assert func(t *testing.T, action screenAction)28+ }{29+ {30+ name: "daily",31+ item: ui.MenuItem{Action: playMenuActionDaily, ItemTitle: "Today", Desc: "custom label"},32+ assert: func(t *testing.T, action screenAction) {33+ t.Helper()34+ if _, ok := action.(openDailyAction); !ok {35+ t.Fatalf("action = %T, want %T", action, openDailyAction{})36+ }37+ },38+ },39+ {40+ name: "seeded",41+ item: ui.MenuItem{Action: playMenuActionSeeded, ItemTitle: "Named Seed", Desc: "custom label"},42+ assert: func(t *testing.T, action screenAction) {43+ t.Helper()44+ if _, ok := action.(openSeedInputAction); !ok {45+ t.Fatalf("action = %T, want %T", action, openSeedInputAction{})46+ }47+ },48+ },49+ }50+51+ for _, tt := range tests {52+ t.Run(tt.name, func(t *testing.T) {53+ screen := playMenuScreen{54+ menu: ui.NewMainMenu([]ui.MenuItem{tt.item}),55+ }56+57+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})58+ tt.assert(t, action)59+ })60+ }61+}62+63+func TestOptionsMenuScreenRoutesByStableAction(t *testing.T) {64+ screen := optionsMenuScreen{65+ menu: ui.NewMainMenu([]ui.MenuItem{66+ {Action: optionsMenuActionGuides, ItemTitle: "Help Docs", Desc: "custom label"},67+ }),68+ }69+70+ _, _, action := screen.Update(tea.KeyPressMsg{Code: tea.KeyEnter})71+ if _, ok := action.(openHelpSelectAction); !ok {72+ t.Fatalf("action = %T, want %T", action, openHelpSelectAction{})73+ }74+}
M ui/mainmenu.go +4 -0
M ui/menuitem.go +8 -0
M ui/ui_test.go +16 -0