- commit
- 4480c349b2dc20e854c7bdd92ade6f553e837035
- parent
- a8fc19e85f86b9127a4ee13507e6dd95b3b5e220
- author
- Dami <dami.etoile@gmail.com>
| M | app/ | 33 | +- |
| M | app/ | 9 | +- |
| M | app/ | 26 | +- |
| M | app/ | 80 | --- |
| M | app/ | 9 | +- |
| M | app/ | 22 | +- |
| A | app/ | 177 | ++++++ |
| A | app/ | 887 | ++++++++++++++++++++++++++++++ |
| A | app/ | 204 | +++++++ |
| M | app/ | 101 | +--- |
| D | app/ | 107 | ---- |
| M | app/ | 56 | -- |
| M | app/ | 423 | +++++--------- |
| M | app/ | 23 | +- |
| M | app/ | 25 | + |
| M | app/ | 9 | +- |
| M | ui/ | 32 | +- |
@@ -1,12 +1,8 @@
11 package app22 33 import (4- "github.com/FelineStateMachine/puzzletea/game"5- sessionflow "github.com/FelineStateMachine/puzzletea/session"64 "github.com/FelineStateMachine/puzzletea/store"75 "github.com/FelineStateMachine/puzzletea/weekly"8-9- tea "charm.land/bubbletea/v2"106 )117 128 type gameOpenOptions struct {@@ -15,36 +11,11 @@ type gameOpenOptions struct {
1511 weeklyInfo *weekly.Info1612 }1713 18-// activateGame prepares the game with global UI state and moves to game view.19-func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model {20- g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull})21- g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})22-23- m.session.game = g24- m.session.activeGameID = activeGameID25- m.session.completionSaved = completionSaved26- m.session.returnState = options.returnState27- m.session.weeklyAdvance = options.weeklyInfo28- m.state = gameView29- return m30-}31-3214 func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {3315 return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView})3416 }3517 3618 func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {37- g, err := sessionflow.ImportRecord(&rec)38- if err != nil {39- return m.setErrorf("Could not load saved puzzle %q: %v", rec.Name, err), false40- }41-42- activeGameID := rec.ID43- completionSaved := rec.Status == store.StatusCompleted44- if options.readOnly {45- activeGameID = 046- completionSaved = true47- }48-49- return m.activateGame(g, activeGameID, completionSaved, options).clearNotice(), true19+ ok := newSessionController(&m).importRecord(rec, options)20+ return m, ok5021 }@@ -35,8 +35,7 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
3535 }3636 3737 rng := daily.RNG(today)38- ctx, jobID := m.beginSpawnContext()39- m.session.spawn = &spawnRequest{38+ cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{4039 source: spawnSourceDaily,4140 name: name,4241 gameType: gameType,@@ -44,8 +43,6 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
4443 run: store.DailyRunMetadata(today),4544 returnState: playMenuView,4645 exitState: mainMenuView,47- }48- m.state = generatingView49- m = m.clearNotice()50- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))46+ })47+ return m, cmd5148 }@@ -28,8 +28,7 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
2828 return m, nil2929 }3030 m.nav.selectedModeTitle = mode.Definition.Title31- ctx, jobID := m.beginSpawnContext()32- m.session.spawn = &spawnRequest{31+ cmd := newSessionController(&m).startSpawn(mode.Spawner, spawnRequest{3332 source: spawnSourceNormal,3433 name: sessionflow.GenerateUniqueName(m.store),3534 gameType: m.nav.selectedCategory.Definition.Name,@@ -37,9 +36,8 @@ func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {
3736 run: store.NormalRunMetadata(),3837 returnState: modeSelectView,3938 exitState: mainMenuView,40- }41- m.state = generatingView42- return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID))39+ })40+ return m, cmd4341 }4442 4543 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {@@ -53,22 +51,6 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
5351 }5452 5553 func (m model) persistCompletionIfSolved() model {56- if m.session.game == nil || m.session.activeGameID == 0 ||57- m.session.completionSaved || !m.session.game.IsSolved() {58- return m59- }60-61- m.session.completionSaved = true62- saveData, err := m.session.game.GetSave()63- if err == nil {64- if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {65- m = m.setErrorf("Puzzle completed, but saving the final state failed: %v", err)66- }67- } else {68- m = m.setErrorf("Puzzle completed, but reading the final state failed: %v", err)69- }70- if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil {71- m = m.setErrorf("Puzzle completed, but updating completion status failed: %v", err)72- }54+ newSessionController(&m).persistCompletionIfSolved()7355 return m7456 }@@ -3,31 +3,11 @@ package app
33 import (44 "time"55 6- "github.com/FelineStateMachine/puzzletea/theme"76 "github.com/FelineStateMachine/puzzletea/ui"87 98 tea "charm.land/bubbletea/v2"109 )1110 12-func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {13- item := m.nav.mainMenu.Selected()14- switch item.Title() {15- case "Play":16- m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))17- m.state = playMenuView18- m = m.clearNotice()19- case "Stats":20- return m.handleStatsEnter()21- case "Options":22- m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)23- m.state = optionsMenuView24- m = m.clearNotice()25- case "Quit":26- return m, tea.Quit27- }28- return m, nil29-}30-3111 func (m model) currentWeeklyMenuIndex() int {3212 if m.store == nil {3313 return 1@@ -47,66 +27,6 @@ func (m model) currentWeeklyMenuIndex() int {
4727 return highestCompleted + 14828 }4929 50-func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {51- item := m.nav.playMenu.Selected()52- switch item.Title() {53- case "Create":54- m.state = gameSelectView55- m = m.updateCategoryDetailViewport()56- m = m.clearNotice()57- case "Continue":58- m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)59- m.state = continueView60- m = m.clearNotice()61- case "Daily":62- return m.handleDailyPuzzle()63- case "Weekly":64- return m.enterWeeklyView()65- case "Seeded":66- return m.enterSeedInputView()67- }68- return m, nil69-}70-71-func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {72- item := m.nav.optionsMenu.Selected()73- switch item.Title() {74- case "Theme":75- return m.handleThemeEnter()76- case "Guides":77- m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")78- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)79- m.help.selectList.SetSize(listWidth, listHeight)80- m.state = helpSelectView81- m = m.clearNotice()82- }83- return m, nil84-}85-86-func (m model) handleEscape() (tea.Model, tea.Cmd) {87- switch m.state {88- case playMenuView, optionsMenuView, statsView:89- m.state = mainMenuView90- case seedInputView, gameSelectView, continueView, weeklyView:91- m.state = playMenuView92- case generatingView:93- returnState := m.activeSpawnReturnState()94- m.cancelActiveSpawn()95- m.state = returnState96- case modeSelectView:97- m.state = gameSelectView98- m = m.updateCategoryDetailViewport()99- case helpDetailView:100- m.state = helpSelectView101- case helpSelectView, themeSelectView:102- if m.state == themeSelectView {103- _ = theme.Apply(m.theme.previous)104- }105- m.state = optionsMenuView106- }107- return m, nil108-}109-11030 func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {11131 keyMsg, ok := msg.(tea.KeyPressMsg)11232 if !ok {@@ -53,8 +53,7 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
5353 }5454 5555 rng := resolve.RNGFromString(seed)56- ctx, jobID := m.beginSpawnContext()57- m.session.spawn = &spawnRequest{56+ cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{5857 source: spawnSourceSeed,5958 name: name,6059 gameType: gameType,@@ -62,8 +61,6 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
6261 run: store.SeededRunMetadata(seed),6362 returnState: playMenuView,6463 exitState: mainMenuView,65- }66- m.state = generatingView67- m = m.clearNotice()68- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))64+ })65+ return m, cmd6966 }@@ -34,14 +34,18 @@ func (m model) setErrorf(format string, args ...any) model {
3434 }3535 3636 func (m model) appendNotice(content string) string {37- if m.notice.message == "" {37+ return appendNoticeContent(m.width, m.notice, content)38+}39+40+func appendNoticeContent(width int, notice noticeState, content string) string {41+ if notice.message == "" {3842 return content3943 }4044 4145 style := ui.ErrorNoticeStyle()4246 block := style.43- MaxWidth(max(m.width-8, 24)).44- Render(m.notice.message)47+ MaxWidth(max(width-8, 24)).48+ Render(notice.message)4549 if content == "" {4650 return block4751 }@@ -49,6 +53,14 @@ func (m model) appendNotice(content string) string {
4953 }5054 5155 func (m model) renderPanel(title, content, footer string) string {52- panel := ui.Panel(title, m.appendNotice(content), footer)53- return ui.CenterView(m.width, m.height, panel)56+ return renderPanelView(m.width, m.height, m.notice, title, content, footer)57+}58+59+func centerContentWithNotice(width, height int, notice noticeState, content string) string {60+ return ui.CenterView(width, height, appendNoticeContent(width, notice, content))61+}62+63+func renderPanelView(width, height int, notice noticeState, title, content, footer string) string {64+ panel := ui.Panel(title, appendNoticeContent(width, notice, content), footer)65+ return ui.CenterView(width, height, panel)5466 }@@ -0,0 +1,177 @@
1+package app2+3+import (4+ "log"5+6+ "github.com/FelineStateMachine/puzzletea/stats"7+ "github.com/FelineStateMachine/puzzletea/theme"8+ "github.com/FelineStateMachine/puzzletea/ui"9+10+ "charm.land/bubbles/v2/list"11+ "charm.land/bubbles/v2/viewport"12+ "github.com/charmbracelet/glamour"13+)14+15+const (16+ helpPanelInsetX = 217+ helpPanelInsetY = 118+ helpPanelHorizontalTrim = 619+ helpPanelVerticalTrim = 820+ categoryPanelChrome = 821+ categoryBodyMaxWidth = 8622+ categoryBodyMaxHeight = 1623+ categoryMinListWidth = 2424+ categoryMaxListWidth = 3025+ categoryGapWidth = 226+ categoryDetailTrimX = 627+ categoryDetailTrimY = 428+ categoryStackGapHeight = 129+ categoryMinSideBySideW = 7230+)31+32+func helpViewportSize(width, height int) (int, int) {33+ panelWidth := max(width-(helpPanelInsetX*2), 1)34+ panelHeight := max(height-(helpPanelInsetY*2), 1)35+ contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1)36+ contentHeight := max(panelHeight-helpPanelVerticalTrim, 1)37+ return contentWidth, contentHeight38+}39+40+func helpSelectListSize(width, height int, l list.Model) (int, int) {41+ contentWidth, contentHeight := helpViewportSize(width, height)42+ listWidth := min(contentWidth, 64)43+ listHeight := min(contentHeight, ui.ListHeight(l))44+ return listWidth, listHeight45+}46+47+func statsViewportSize(width, height int, cards []stats.Card) (int, int) {48+ contentWidth, _ := helpViewportSize(width, height)49+ panelHeight := max(height-(helpPanelInsetY*2), 1)50+ contentHeight := max(panelHeight-stats.StaticHeight(cards), 1)51+ return contentWidth, contentHeight52+}53+54+type categoryPickerMetrics struct {55+ bodyWidth int56+ bodyHeight int57+ listWidth int58+ listHeight int59+ detailWidth int60+ detailHeight int61+ stacked bool62+}63+64+func categoryPickerSize(width, height int) categoryPickerMetrics {65+ bodyWidth := min(width, categoryBodyMaxWidth)66+ bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight)67+68+ if bodyWidth < categoryMinSideBySideW {69+ listHeight := min(bodyHeight, categoryPickerListHeight())70+ detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1)71+ if detailHeight == 1 && bodyHeight > 1 {72+ listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1)73+ }74+ return categoryPickerMetrics{75+ bodyWidth: bodyWidth,76+ bodyHeight: bodyHeight,77+ listWidth: bodyWidth,78+ listHeight: listHeight,79+ detailWidth: bodyWidth,80+ detailHeight: detailHeight,81+ stacked: true,82+ }83+ }84+85+ listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3))86+ detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1)87+ return categoryPickerMetrics{88+ bodyWidth: bodyWidth,89+ bodyHeight: bodyHeight,90+ listWidth: listWidth,91+ listHeight: bodyHeight,92+ detailWidth: detailWidth,93+ detailHeight: bodyHeight,94+ }95+}96+97+func selectedCategoryName(item list.Item) string {98+ entry, ok := selectedCategoryEntry(item)99+ if !ok {100+ return ""101+ }102+ return entry.Definition.Name103+}104+105+func (m model) updateCategoryDetailViewport() model {106+ metrics := categoryPickerSize(m.width, m.height)107+ contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1)108+ contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1)109+110+ if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 {111+ m.nav.categoryDetail = viewport.New(112+ viewport.WithWidth(contentWidth),113+ viewport.WithHeight(contentHeight),114+ )115+ }116+ m.nav.categoryDetail.SetWidth(contentWidth)117+ m.nav.categoryDetail.SetHeight(contentHeight)118+ m.nav.categoryDetail.FillHeight = true119+120+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())121+ if !ok {122+ m.nav.categoryDetail.SetContent("")123+ return m124+ }125+126+ m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth))127+ m.nav.categoryDetail.GotoTop()128+ return m129+}130+131+func (m model) updateHelpDetailViewport() model {132+ helpWidth, helpHeight := helpViewportSize(m.width, m.height)133+ palette := theme.Current()134+ themeKey := helpMarkdownThemeKey(palette)135+ if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {136+ renderer, err := glamour.NewTermRenderer(137+ glamour.WithStyles(helpMarkdownStyle(palette)),138+ glamour.WithWordWrap(helpWidth),139+ glamour.WithChromaFormatter("terminal16m"),140+ )141+ if err != nil {142+ log.Printf("failed to create help renderer: %v", err)143+ m.help.renderer = nil144+ m.help.rendererWidth = 0145+ m.help.rendererTheme = ""146+ } else {147+ m.help.renderer = renderer148+ m.help.rendererWidth = helpWidth149+ m.help.rendererTheme = themeKey150+ }151+ }152+153+ rendered := m.help.category.Help154+ if m.help.renderer != nil {155+ out, err := m.help.renderer.Render(m.help.category.Help)156+ if err != nil {157+ log.Printf("failed to render help: %v", err)158+ } else {159+ rendered = out160+ }161+ }162+163+ m.help.viewport = viewport.New(164+ viewport.WithWidth(helpWidth),165+ viewport.WithHeight(helpHeight),166+ )167+ m.help.viewport.SetContent(rendered)168+ return m169+}170+171+func (m model) updateStatsViewport() model {172+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)173+ m.stats.viewport.SetWidth(statsWidth)174+ m.stats.viewport.SetHeight(statsHeight)175+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))176+ return m177+}@@ -0,0 +1,887 @@
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+}@@ -0,0 +1,204 @@
1+package app2+3+import (4+ "context"5+ "math/rand/v2"6+7+ "github.com/FelineStateMachine/puzzletea/game"8+ sessionflow "github.com/FelineStateMachine/puzzletea/session"9+ "github.com/FelineStateMachine/puzzletea/store"10+11+ tea "charm.land/bubbletea/v2"12+)13+14+type sessionController struct {15+ model *model16+}17+18+func newSessionController(m *model) sessionController {19+ return sessionController{model: m}20+}21+22+func (c sessionController) beginSpawnContext() (context.Context, int64) {23+ c.cancelActiveSpawn()24+ c.model.session.spawnJobID++25+ jobID := c.model.session.spawnJobID26+ ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)27+ c.model.session.spawnCancel = cancel28+ c.model.session.generating = true29+ return ctx, jobID30+}31+32+func (c sessionController) cancelActiveSpawn() {33+ if c.model.session.spawnCancel != nil {34+ c.model.session.spawnCancel()35+ c.model.session.spawnCancel = nil36+ }37+ if c.model.session.generating {38+ c.model.session.spawnJobID++39+ }40+ c.model.session.generating = false41+ c.model.session.spawn = nil42+}43+44+func (c sessionController) startSpawn(spawner game.Spawner, request spawnRequest) tea.Cmd {45+ ctx, jobID := c.beginSpawnContext()46+ c.model.session.spawn = &request47+ c.model.state = generatingView48+ *c.model = c.model.clearNotice()49+ return tea.Batch(c.model.spinner.Tick, spawnCmd(spawner, ctx, jobID))50+}51+52+func (c sessionController) startSeededSpawn(spawner game.SeededSpawner, rng *rand.Rand, request spawnRequest) tea.Cmd {53+ ctx, jobID := c.beginSpawnContext()54+ c.model.session.spawn = &request55+ c.model.state = generatingView56+ *c.model = c.model.clearNotice()57+ return tea.Batch(c.model.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))58+}59+60+func (c sessionController) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) tea.Cmd {61+ if jobID != c.model.session.spawnJobID {62+ return nil63+ }64+65+ c.model.session.generating = false66+ if c.model.session.spawnCancel != nil {67+ c.model.session.spawnCancel()68+ c.model.session.spawnCancel = nil69+ }70+ request := c.model.session.spawn71+ c.model.session.spawn = nil72+73+ if c.model.state != generatingView {74+ return nil75+ }76+77+ if msg.Err != nil {78+ *c.model = c.model.setErrorf("Could not generate puzzle: %v", msg.Err)79+ if request != nil {80+ c.model.state = request.returnState81+ }82+ return nil83+ }84+85+ if request == nil {86+ *c.model = c.model.setErrorf("Internal error: missing spawn request metadata")87+ return nil88+ }89+90+ c.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{91+ returnState: request.exitState,92+ weeklyInfo: request.weeklyInfo,93+ })94+ *c.model = c.model.clearNotice()95+96+ rec, err := sessionflow.CreateRecord(97+ c.model.store,98+ c.model.session.game,99+ request.name,100+ request.gameType,101+ request.modeTitle,102+ request.run,103+ )104+ if err != nil {105+ *c.model = c.model.setErrorf("Started puzzle, but could not create a save record: %v", err)106+ } else {107+ c.model.session.activeGameID = rec.ID108+ }109+ return nil110+}111+112+func (c sessionController) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) {113+ g, _ = g.Update(game.HelpToggleMsg{Show: c.model.help.showFull})114+ g, _ = g.Update(tea.WindowSizeMsg{Width: c.model.width, Height: c.model.height})115+116+ c.model.session.game = g117+ c.model.session.activeGameID = activeGameID118+ c.model.session.completionSaved = completionSaved119+ c.model.session.returnState = options.returnState120+ c.model.session.weeklyAdvance = options.weeklyInfo121+ c.model.state = gameView122+}123+124+func (c sessionController) importRecord(rec store.GameRecord, options gameOpenOptions) bool {125+ g, err := sessionflow.ImportRecord(&rec)126+ if err != nil {127+ *c.model = c.model.setErrorf("Could not load saved puzzle %q: %v", rec.Name, err)128+ return false129+ }130+131+ activeGameID := rec.ID132+ completionSaved := rec.Status == store.StatusCompleted133+ if options.readOnly {134+ activeGameID = 0135+ completionSaved = true136+ }137+138+ c.activateGame(g, activeGameID, completionSaved, options)139+ *c.model = c.model.clearNotice()140+ return true141+}142+143+func (c sessionController) updateActiveGame(msg tea.Msg) tea.Cmd {144+ var cmd tea.Cmd145+ c.model.session.game, cmd = c.model.session.game.Update(msg)146+ if c.model.debug.enabled {147+ c.model.debug.info = c.model.renderDebugInfo()148+ }149+ c.persistCompletionIfSolved()150+ return cmd151+}152+153+func (c sessionController) persistCompletionIfSolved() {154+ if c.model.session.game == nil || c.model.session.activeGameID == 0 ||155+ c.model.session.completionSaved || !c.model.session.game.IsSolved() {156+ return157+ }158+159+ c.model.session.completionSaved = true160+ saveData, err := c.model.session.game.GetSave()161+ if err == nil {162+ if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil {163+ *c.model = c.model.setErrorf("Puzzle completed, but saving the final state failed: %v", err)164+ }165+ } else {166+ *c.model = c.model.setErrorf("Puzzle completed, but reading the final state failed: %v", err)167+ }168+ if err := c.model.store.UpdateStatus(c.model.session.activeGameID, store.StatusCompleted); err != nil {169+ *c.model = c.model.setErrorf("Puzzle completed, but updating completion status failed: %v", err)170+ }171+}172+173+func (c sessionController) saveCurrentGame(status store.GameStatus) {174+ if c.model.session.game == nil {175+ return176+ }177+ if c.model.session.activeGameID == 0 {178+ c.clearActiveGame()179+ return180+ }181+182+ saveData, err := c.model.session.game.GetSave()183+ if err != nil {184+ *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err)185+ return186+ }187+ if err := c.model.store.UpdateSaveState(c.model.session.activeGameID, string(saveData)); err != nil {188+ *c.model = c.model.setErrorf("Could not save puzzle progress: %v", err)189+ }190+ if !(c.model.session.completionSaved && status != store.StatusCompleted) {191+ if err := c.model.store.UpdateStatus(c.model.session.activeGameID, status); err != nil {192+ *c.model = c.model.setErrorf("Could not update puzzle status: %v", err)193+ }194+ }195+ c.clearActiveGame()196+}197+198+func (c sessionController) clearActiveGame() {199+ c.model.session.activeGameID = 0200+ c.model.session.game = nil201+ c.model.session.completionSaved = false202+ c.model.session.returnState = mainMenuView203+ c.model.session.weeklyAdvance = nil204+}@@ -6,7 +6,6 @@ import (
66 "time"77 88 "github.com/FelineStateMachine/puzzletea/game"9- sessionflow "github.com/FelineStateMachine/puzzletea/session"109 "github.com/FelineStateMachine/puzzletea/store"1110 1211 tea "charm.land/bubbletea/v2"@@ -59,109 +58,17 @@ func spawnSeededCmd(spawner game.SeededSpawner, rng *rand.Rand, ctx context.Cont
5958 }6059 }6160 62-func (m *model) beginSpawnContext() (context.Context, int64) {63- m.cancelActiveSpawn()64- m.session.spawnJobID++65- jobID := m.session.spawnJobID66- ctx, cancel := context.WithTimeout(context.Background(), spawnTimeout)67- m.session.spawnCancel = cancel68- m.session.generating = true69- return ctx, jobID70-}71-7261 func (m *model) cancelActiveSpawn() {73- if m.session.spawnCancel != nil {74- m.session.spawnCancel()75- m.session.spawnCancel = nil76- }77- if m.session.generating {78- // Invalidate late completion messages from a canceled job.79- m.session.spawnJobID++80- }81- m.session.generating = false82- m.session.spawn = nil62+ newSessionController(m).cancelActiveSpawn()8363 }8464 8565 func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.Model, tea.Cmd) {86- if jobID != m.session.spawnJobID {87- return m, nil88- }89-90- m.session.generating = false91- if m.session.spawnCancel != nil {92- m.session.spawnCancel()93- m.session.spawnCancel = nil94- }95- request := m.session.spawn96- m.session.spawn = nil97-98- // If the user navigated away while generating, discard the result.99- if m.state != generatingView {100- return m, nil101- }102-103- if msg.Err != nil {104- m = m.setErrorf("Could not generate puzzle: %v", msg.Err)105- if request != nil {106- m.state = request.returnState107- }108- return m, nil109- }110-111- if request == nil {112- return m.setErrorf("Internal error: missing spawn request metadata"), nil113- }114- m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{115- returnState: request.exitState,116- weeklyInfo: request.weeklyInfo,117- }).clearNotice()118-119- // Capture initial state and create DB record.120- rec, err := sessionflow.CreateRecord(121- m.store,122- m.session.game,123- request.name,124- request.gameType,125- request.modeTitle,126- request.run,127- )128- if err != nil {129- m = m.setErrorf("Started puzzle, but could not create a save record: %v", err)130- } else {131- m.session.activeGameID = rec.ID132- }133- return m, nil66+ cmd := newSessionController(&m).handleSpawnComplete(jobID, msg)67+ return m, cmd13468 }13569 13670 // saveCurrentGame saves the current game state to the DB if a game is active.13771 func saveCurrentGame(m model, status store.GameStatus) model {138- if m.session.game == nil {139- return m140- }141- if m.session.activeGameID == 0 {142- return clearActiveGame(m)143- }144- saveData, err := m.session.game.GetSave()145- if err != nil {146- return m.setErrorf("Could not save puzzle progress: %v", err)147- }148- if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {149- m = m.setErrorf("Could not save puzzle progress: %v", err)150- }151- // Don't overwrite a completed status when navigating away.152- if !(m.session.completionSaved && status != store.StatusCompleted) {153- if err := m.store.UpdateStatus(m.session.activeGameID, status); err != nil {154- m = m.setErrorf("Could not update puzzle status: %v", err)155- }156- }157- return clearActiveGame(m)158-}159-160-func clearActiveGame(m model) model {161- m.session.activeGameID = 0162- m.session.game = nil163- m.session.completionSaved = false164- m.session.returnState = mainMenuView165- m.session.weeklyAdvance = nil72+ newSessionController(&m).saveCurrentGame(status)16673 return m16774 }@@ -1,107 +0,0 @@
1-package app2-3-import (4- "github.com/FelineStateMachine/puzzletea/game"5- "github.com/FelineStateMachine/puzzletea/theme"6- "github.com/FelineStateMachine/puzzletea/ui"7-8- tea "charm.land/bubbletea/v2"9-)10-11-func (m model) updateActiveState(msg tea.Msg) (model, tea.Cmd) {12- var cmd tea.Cmd13-14- switch m.state {15- case mainMenuView:16- updateMainMenuCursor(msg, &m.nav.mainMenu)17- case playMenuView:18- updateMainMenuCursor(msg, &m.nav.playMenu)19- case optionsMenuView:20- updateMainMenuCursor(msg, &m.nav.optionsMenu)21- case seedInputView:22- m, cmd = m.handleSeedInputUpdate(msg)23- case generatingView:24- m.spinner, cmd = m.spinner.Update(msg)25- case gameView:26- m.session.game, cmd = m.session.game.Update(msg)27- if m.debug.enabled {28- m.debug.info = m.renderDebugInfo()29- }30- m = m.persistCompletionIfSolved()31- case gameSelectView:32- prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem())33- m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)34- if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev {35- m = m.updateCategoryDetailViewport()36- }37- case modeSelectView:38- m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)39- case continueView:40- m.cont.table, cmd = m.cont.table.Update(msg)41- case weeklyView:42- m.weekly.table, cmd = m.weekly.table.Update(msg)43- case helpSelectView:44- m.help.selectList, cmd = m.help.selectList.Update(msg)45- case helpDetailView:46- m.help.viewport, cmd = m.help.viewport.Update(msg)47- case statsView:48- m.stats.viewport, cmd = m.stats.viewport.Update(msg)49- case themeSelectView:50- prev := m.theme.list.Index()51- m.theme.list, cmd = m.theme.list.Update(msg)52- if m.theme.list.Index() != prev {53- if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {54- _ = theme.Apply(item.ItemTitle)55- ui.UpdateThemeListStyles(&m.theme.list)56- }57- }58- }59-60- return m, cmd61-}62-63-func (m model) resizeActiveState(menuW int) model {64- switch m.state {65- case seedInputView:66- m.seed.input.SetWidth(min(m.width, 48))67- case modeSelectView:68- m.nav.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.modeSelectList)))69- case continueView:70- m.cont.table.SetWidth(m.width)71- visibleRows := min(len(m.cont.games), ui.MaxTableRows)72- m.cont.table.SetHeight(min(m.height, visibleRows))73- case weeklyView:74- m = m.refreshWeeklyBrowser()75- case helpSelectView:76- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)77- m.help.selectList.SetSize(listWidth, listHeight)78- case helpDetailView:79- m = m.updateHelpDetailViewport()80- case themeSelectView:81- const maxVisibleItems = 882- listW := min(menuW, theme.MaxNameLen+4)83- m.theme.list.SetSize(listW, min(m.height, maxVisibleItems*3))84- case statsView:85- m = m.updateStatsViewport()86- }87-88- return m89-}90-91-func (m model) handleFrameworkMsg(msg tea.Msg) (model, tea.Cmd, bool) {92- switch msg := msg.(type) {93- case spawnCompleteMsg:94- next, cmd := m.handleSpawnComplete(msg.jobID, msg.result)95- return next.(model), cmd, true96- case game.SpawnCompleteMsg:97- next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)98- return next.(model), cmd, true99- case tea.WindowSizeMsg:100- return m.handleWindowSize(msg), nil, false101- case tea.KeyPressMsg:102- next, cmd, done := m.handleGlobalKey(msg)103- return next, cmd, done104- default:105- return m, nil, false106- }107-}@@ -1,67 +1,11 @@
11 package app22 33 import (4- "fmt"5-64 "github.com/FelineStateMachine/puzzletea/ui"75 86 "charm.land/lipgloss/v2"97 )108 11-func (m model) viewContent() string {12- switch m.state {13- case mainMenuView:14- return ui.CenterView(m.width, m.height, m.appendNotice(m.nav.mainMenu.View()))15- case playMenuView:16- return ui.CenterView(m.width, m.height, m.appendNotice(m.nav.playMenu.ViewAsPanel("Play")))17- case optionsMenuView:18- items := m.nav.optionsMenu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")19- return m.renderPanel("Options", items, "↑/↓ navigate • enter select • esc back")20- case seedInputView:21- return m.renderPanel(22- "Enter Seed",23- m.seedInputBody(),24- "↑/↓ change field • ←/→ game • enter confirm • esc back",25- )26- case gameSelectView:27- return m.gameSelectViewContent()28- case modeSelectView:29- return m.renderPanel(30- m.nav.selectedCategory.Definition.Name+" — Select Mode",31- m.nav.modeSelectList.View(),32- "↑/↓ navigate • enter select • esc back",33- )34- case generatingView:35- s := m.spinner.View() + " Generating puzzle..."36- box := ui.GeneratingFrame().Render(m.appendNotice(s))37- return ui.CenterView(m.width, m.height, box)38- case continueView:39- return m.renderContinueView()40- case weeklyView:41- return ui.CenterView(m.width, m.height, m.appendNotice(m.weeklyViewContent()))42- case gameView:43- return m.renderGameView()44- case helpSelectView:45- return m.renderPanel(46- "How to Play",47- m.help.selectList.View(),48- "↑/↓ navigate • enter select • esc back",49- )50- case helpDetailView:51- return m.renderPanel(52- m.help.category.Definition.Name+" — Guide",53- m.help.viewport.View(),54- "↑/↓ scroll • esc back",55- )56- case themeSelectView:57- return m.themeSelectViewContent()58- case statsView:59- return m.renderStatsView()60- default:61- return fmt.Sprintf("unknown state: %d", m.state)62- }63-}64-659 func (m model) renderContinueView() string {6610 if len(m.cont.games) == 0 {6711 return m.renderPanel("Saved Games", "No saved games yet.", "esc back")@@ -1,299 +1,143 @@
11 package app22 33 import (4- "log"4+ "time"55 66 "github.com/FelineStateMachine/puzzletea/game"7- "github.com/FelineStateMachine/puzzletea/stats"87 "github.com/FelineStateMachine/puzzletea/store"98 "github.com/FelineStateMachine/puzzletea/theme"109 "github.com/FelineStateMachine/puzzletea/ui"1110 1211 "charm.land/bubbles/v2/key"13- "charm.land/bubbles/v2/list"14- "charm.land/bubbles/v2/viewport"1512 tea "charm.land/bubbletea/v2"16- "github.com/charmbracelet/glamour"1713 )1814 19-const (20- helpPanelInsetX = 221- helpPanelInsetY = 122- helpPanelHorizontalTrim = 623- helpPanelVerticalTrim = 824- categoryPanelChrome = 825- categoryBodyMaxWidth = 8626- categoryBodyMaxHeight = 1627- categoryMinListWidth = 2428- categoryMaxListWidth = 3029- categoryGapWidth = 230- categoryDetailTrimX = 631- categoryDetailTrimY = 432- categoryStackGapHeight = 133- categoryMinSideBySideW = 7234-)35-36-func helpViewportSize(width, height int) (int, int) {37- panelWidth := max(width-(helpPanelInsetX*2), 1)38- panelHeight := max(height-(helpPanelInsetY*2), 1)39- contentWidth := max(panelWidth-helpPanelHorizontalTrim, 1)40- contentHeight := max(panelHeight-helpPanelVerticalTrim, 1)41- return contentWidth, contentHeight42-}43-44-func helpSelectListSize(width, height int, l list.Model) (int, int) {45- contentWidth, contentHeight := helpViewportSize(width, height)46- listWidth := min(contentWidth, 64)47- listHeight := min(contentHeight, ui.ListHeight(l))48- return listWidth, listHeight49-}50-51-func statsViewportSize(width, height int, cards []stats.Card) (int, int) {52- contentWidth, _ := helpViewportSize(width, height)53- panelHeight := max(height-(helpPanelInsetY*2), 1)54- contentHeight := max(panelHeight-stats.StaticHeight(cards), 1)55- return contentWidth, contentHeight56-}57-58-type categoryPickerMetrics struct {59- bodyWidth int60- bodyHeight int61- listWidth int62- listHeight int63- detailWidth int64- detailHeight int65- stacked bool66-}67-68-func categoryPickerSize(width, height int) categoryPickerMetrics {69- bodyWidth := min(width, categoryBodyMaxWidth)70- bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight)71-72- if bodyWidth < categoryMinSideBySideW {73- listHeight := min(bodyHeight, categoryPickerListHeight())74- detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1)75- if detailHeight == 1 && bodyHeight > 1 {76- listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1)77- }78- return categoryPickerMetrics{79- bodyWidth: bodyWidth,80- bodyHeight: bodyHeight,81- listWidth: bodyWidth,82- listHeight: listHeight,83- detailWidth: bodyWidth,84- detailHeight: detailHeight,85- stacked: true,15+func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {16+ switch msg := msg.(type) {17+ case spawnCompleteMsg:18+ next, cmd := m.handleSpawnComplete(msg.jobID, msg.result)19+ return next, cmd20+ case game.SpawnCompleteMsg:21+ next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)22+ return next, cmd23+ case tea.WindowSizeMsg:24+ m = m.handleWindowSize(msg)25+ if m.state != gameView {26+ return m, nil8627 }8728 }8829 89- listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3))90- detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1)91- return categoryPickerMetrics{92- bodyWidth: bodyWidth,93- bodyHeight: bodyHeight,94- listWidth: listWidth,95- listHeight: bodyHeight,96- detailWidth: detailWidth,97- detailHeight: bodyHeight,98- }99-}100-101-func selectedCategoryName(item list.Item) string {102- entry, ok := selectedCategoryEntry(item)103- if !ok {104- return ""105- }106- return entry.Definition.Name107-}108-109-func activeFilterList(m model) *list.Model {110- switch m.state {111- case gameSelectView:112- return &m.nav.gameSelectList113- case themeSelectView:114- return &m.theme.list115- default:116- return nil117- }118-}119-120-func (m model) updateCategoryDetailViewport() model {121- metrics := categoryPickerSize(m.width, m.height)122- contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1)123- contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1)124-125- if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 {126- m.nav.categoryDetail = viewport.New(127- viewport.WithWidth(contentWidth),128- viewport.WithHeight(contentHeight),129- )30+ next, cmd, handled := m.handleGlobalKey(msg)31+ if handled {32+ return next, cmd13033 }131- m.nav.categoryDetail.SetWidth(contentWidth)132- m.nav.categoryDetail.SetHeight(contentHeight)133- m.nav.categoryDetail.FillHeight = true34+ m = next13435 135- entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())136- if !ok {137- m.nav.categoryDetail.SetContent("")138- return m36+ if m.state == gameView {37+ gameCmd := newSessionController(&m).updateActiveGame(msg)38+ return m, gameCmd13939 }14040 141- m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth))142- m.nav.categoryDetail.GotoTop()143- return m144-}145-146-func (m model) updateHelpDetailViewport() model {147- helpWidth, helpHeight := helpViewportSize(m.width, m.height)148- palette := theme.Current()149- themeKey := helpMarkdownThemeKey(palette)150- if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {151- renderer, err := glamour.NewTermRenderer(152- glamour.WithStyles(helpMarkdownStyle(palette)),153- glamour.WithWordWrap(helpWidth),154- glamour.WithChromaFormatter("terminal16m"),155- )156- if err != nil {157- log.Printf("failed to create help renderer: %v", err)158- m.help.renderer = nil159- m.help.rendererWidth = 0160- m.help.rendererTheme = ""161- } else {162- m.help.renderer = renderer163- m.help.rendererWidth = helpWidth164- m.help.rendererTheme = themeKey165- }41+ screen := m.activeScreen()42+ if screen == nil {43+ return m, nil16644 }16745 168- rendered := m.help.category.Help169- if m.help.renderer != nil {170- out, err := m.help.renderer.Render(m.help.category.Help)171- if err != nil {172- log.Printf("failed to render help: %v", err)173- } else {174- rendered = out175- }46+ nextScreen, screenCmd, action := screen.Update(msg)47+ m = nextScreen.Apply(m)48+ if action == nil {49+ return m, screenCmd17650 }17751 178- m.help.viewport = viewport.New(179- viewport.WithWidth(helpWidth),180- viewport.WithHeight(helpHeight),181- )182- m.help.viewport.SetContent(rendered)183- return m52+ next, actionCmd := m.handleScreenAction(action)53+ return next, tea.Batch(screenCmd, actionCmd)18454 }18555 186-func (m model) updateStatsViewport() model {187- statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)188- m.stats.viewport.SetWidth(statsWidth)189- m.stats.viewport.SetHeight(statsHeight)190- m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))191- return m192-}193-194-func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {195- next, cmd, handled := m.handleFrameworkMsg(msg)196- if handled {197- return next, cmd56+func (m model) resizeActiveScreen() model {57+ screen := m.activeScreen()58+ if screen == nil {59+ return m19860 }199-200- m = next201- return m.updateActiveState(msg)61+ return screen.Resize(m.width, m.height).Apply(m)20262 }20363 20464 func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {20565 m.width = msg.Width20666 m.height = msg.Height207- menuW := min(m.width, 64)208- metrics := categoryPickerSize(m.width, m.height)209- m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)210- m = m.updateCategoryDetailViewport()211- return m.resizeActiveState(menuW)67+ if m.state == gameView {68+ return m69+ }70+ return m.resizeActiveScreen()21271 }21372 214-func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {73+func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {74+ keyMsg, ok := msg.(tea.KeyPressMsg)75+ if !ok {76+ return m, nil, false77+ }78+21579 if m.state == generatingView {21680 switch {217- case key.Matches(msg, rootKeys.Escape):81+ case key.Matches(keyMsg, rootKeys.Escape):21882 returnState := m.activeSpawnReturnState()21983 m.cancelActiveSpawn()22084 m.state = returnState221- return m, nil, true222- case key.Matches(msg, rootKeys.Quit):85+ return m.resizeActiveScreen(), nil, true86+ case key.Matches(keyMsg, rootKeys.Quit):22387 return m, tea.Quit, true22488 }22589 return m, nil, true22690 }22791 228- if l := activeFilterList(m); l != nil {92+ if m.state == gameView {22993 switch {230- case l.SettingFilter() && l.FilterValue() == "" && key.Matches(msg, rootKeys.Enter):231- l.ResetFilter()232- if m.state == gameSelectView {233- m = m.updateCategoryDetailViewport()94+ case key.Matches(keyMsg, rootKeys.Enter):95+ next, cmd, handled := m.advanceSolvedWeekly()96+ if handled {97+ return next, cmd, true98+ }99+ case key.Matches(keyMsg, rootKeys.Escape):100+ returnState := m.session.returnState101+ m = saveCurrentGame(m, store.StatusInProgress)102+ m.state = returnState103+ if returnState == weeklyView {104+ m = m.refreshWeeklyBrowser()105+ }106+ m.debug.enabled = false107+ return m, nil, true108+ case key.Matches(keyMsg, rootKeys.Quit):109+ m = saveCurrentGame(m, store.StatusAbandoned)110+ return m, tea.Quit, true111+ case key.Matches(keyMsg, rootKeys.Debug):112+ m.debug.enabled = !m.debug.enabled113+ return m, nil, true114+ case key.Matches(keyMsg, rootKeys.FullHelp):115+ m.help.showFull = !m.help.showFull116+ if m.session.game != nil {117+ m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull})118+ }119+ return m, nil, true120+ case key.Matches(keyMsg, rootKeys.ResetGame):121+ if m.session.game != nil {122+ m.session.game = m.session.game.Reset()234123 }235124 return m, nil, true236- case l.SettingFilter() && key.Matches(msg, rootKeys.Enter):237- return m, nil, false238- case l.FilterState() != list.Unfiltered && key.Matches(msg, rootKeys.Escape):239- return m, nil, false240125 }126+ return m, nil, false241127 }242128 243129 switch {244- case m.state == weeklyView && (msg.String() == "left" || msg.String() == "h"):245- m = m.moveWeeklyWeek(-1)246- return m, nil, true247- case m.state == weeklyView && (msg.String() == "right" || msg.String() == "l"):248- m = m.moveWeeklyWeek(1)249- return m, nil, true250- case m.state == gameView && key.Matches(msg, rootKeys.Enter):251- next, cmd, handled := m.advanceSolvedWeekly()252- if handled {253- return next, cmd, true254- }255- case m.state == gameSelectView && msg.String() == "pgup":256- m.nav.categoryDetail.PageUp()257- return m, nil, true258- case m.state == gameSelectView && msg.String() == "pgdown":259- m.nav.categoryDetail.PageDown()260- return m, nil, true261- case m.state == gameView && key.Matches(msg, rootKeys.Escape):262- returnState := m.session.returnState263- m = saveCurrentGame(m, store.StatusInProgress)264- m.state = returnState265- if returnState == weeklyView {266- m = m.refreshWeeklyBrowser()267- }268- m.debug.enabled = false269- return m, nil, true270- case key.Matches(msg, rootKeys.Enter):271- if m.state != gameView {272- next, cmd := m.handleEnter()273- return next.(model), cmd, true274- }275- case key.Matches(msg, rootKeys.Escape):276- if m.state != gameView {277- next, cmd := m.handleEscape()278- return next.(model), cmd, true279- }280- case key.Matches(msg, rootKeys.Quit):281- m = saveCurrentGame(m, store.StatusAbandoned)130+ case key.Matches(keyMsg, rootKeys.Quit):282131 return m, tea.Quit, true283- case key.Matches(msg, rootKeys.Debug):132+ case key.Matches(keyMsg, rootKeys.Debug):284133 m.debug.enabled = !m.debug.enabled285- case key.Matches(msg, rootKeys.FullHelp):134+ return m, nil, true135+ case key.Matches(keyMsg, rootKeys.FullHelp):286136 m.help.showFull = !m.help.showFull287- if m.state == gameView && m.session.game != nil {288- m.session.game, _ = m.session.game.Update(game.HelpToggleMsg{Show: m.help.showFull})289- }290- case key.Matches(msg, rootKeys.ResetGame):291- if m.state == gameView && m.session.game != nil {292- m.session.game = m.session.game.Reset()293- }137+ return m, nil, true138+ default:139+ return m, nil, false294140 }295-296- return m, nil, false297141 }298142 299143 func (m model) activeSpawnReturnState() viewState {@@ -303,28 +147,79 @@ func (m model) activeSpawnReturnState() viewState {
303147 return m.session.spawn.returnState304148 }305149 306-func (m model) handleEnter() (tea.Model, tea.Cmd) {307- switch m.state {308- case mainMenuView:309- return m.handleMainMenuEnter()310- case playMenuView:311- return m.handlePlayMenuEnter()312- case optionsMenuView:313- return m.handleOptionsMenuEnter()314- case seedInputView:315- return m.handleSeedConfirm()316- case gameSelectView:317- return m.handleGameSelectEnter()318- case modeSelectView:319- return m.handleModeSelectEnter()320- case continueView:321- return m.handleContinueEnter()322- case weeklyView:323- return m.handleWeeklyEnter()324- case helpSelectView:325- return m.handleHelpSelectEnter()326- case themeSelectView:327- return m.handleThemeConfirm()150+func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {151+ switch action := action.(type) {152+ case openPlayMenuAction:153+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))154+ m.state = playMenuView155+ m = m.clearNotice()156+ return m.resizeActiveScreen(), nil157+ case openStatsAction:158+ return asModel(m.handleStatsEnter())159+ case openOptionsMenuAction:160+ m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)161+ m.state = optionsMenuView162+ m = m.clearNotice()163+ return m.resizeActiveScreen(), nil164+ case quitAction:165+ return m, tea.Quit166+ case openGameSelectAction:167+ m.state = gameSelectView168+ m = m.updateCategoryDetailViewport()169+ m = m.clearNotice()170+ return m.resizeActiveScreen(), nil171+ case openContinueAction:172+ m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)173+ m.state = continueView174+ m = m.clearNotice()175+ return m.resizeActiveScreen(), nil176+ case openDailyAction:177+ return asModel(m.handleDailyPuzzle())178+ case openWeeklyAction:179+ return asModel(m.enterWeeklyView())180+ case openSeedInputAction:181+ return m.enterSeedInputView()182+ case backAction:183+ if m.state == themeSelectView {184+ _ = theme.Apply(m.theme.previous)185+ }186+ m.state = action.target187+ return m.resizeActiveScreen(), nil188+ case gameSelectEnterAction:189+ return asModel(m.handleGameSelectEnter())190+ case modeSelectEnterAction:191+ return asModel(m.handleModeSelectEnter())192+ case continueEnterAction:193+ return asModel(m.handleContinueEnter())194+ case weeklyShiftAction:195+ m = m.moveWeeklyWeek(action.delta)196+ return m, nil197+ case weeklyEnterAction:198+ return asModel(m.handleWeeklyEnter())199+ case helpSelectEnterAction:200+ return asModel(m.handleHelpSelectEnter())201+ case openThemeSelectAction:202+ return asModel(m.handleThemeEnter())203+ case openHelpSelectAction:204+ m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")205+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)206+ m.help.selectList.SetSize(listWidth, listHeight)207+ m.state = helpSelectView208+ m = m.clearNotice()209+ return m.resizeActiveScreen(), nil210+ case previewThemeAction:211+ _ = theme.Apply(action.name)212+ ui.UpdateThemeListStyles(&m.theme.list)213+ return m, nil214+ case confirmThemeAction:215+ return asModel(m.handleThemeConfirm())216+ case seedConfirmAction:217+ return asModel(m.handleSeedConfirm())218+ default:219+ return m, nil328220 }329- return m, nil221+}222+223+func asModel(next tea.Model, cmd tea.Cmd) (model, tea.Cmd) {224+ return next.(model), cmd330225 }@@ -22,6 +22,18 @@ func (m model) View() tea.View {
2222 return v2323 }2424 25+func (m model) viewContent() string {26+ if m.state == gameView {27+ return m.renderGameView()28+ }29+30+ screen := m.activeScreen()31+ if screen == nil {32+ return fmt.Sprintf("unknown state: %d", m.state)33+ }34+ return screen.View(m.notice)35+}36+2537 func (m model) gameSelectViewContent() string {2638 metrics := categoryPickerSize(m.width, m.height)2739 @@ -167,27 +179,20 @@ func renderModeList(entry registry.Entry, width int) string {
167179 return strings.Join(lines, "\n")168180 }169181 170-// themeSelectViewContent renders the theme picker as a side-by-side layout:171-// theme list on the left, color preview panel on the right.172182 func (m model) themeSelectViewContent() string {173183 p := theme.Current()174184 175- // Determine selected theme name for the preview.176185 themeName := theme.DefaultThemeName177186 if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {178187 themeName = item.ItemTitle179188 }180189 181- // Compute available inner height for the panel content.182- // Panel chrome: border (2) + padding (2) + title (1) + blank (1) + footer (1) + blank (1) = 8183190 const panelChrome = 8184191 innerH := m.height - panelChrome185192 innerH = max(innerH, 10)186193 187- // Left side: theme list.188194 listView := m.theme.list.View()189195 190- // Right side: color preview.191196 previewBorder := lipgloss.NewStyle().192197 Border(lipgloss.RoundedBorder()).193198 BorderForeground(p.Border).@@ -196,9 +201,7 @@ func (m model) themeSelectViewContent() string {
196201 preview := theme.PreviewPanel(themeName, innerH-4)197202 previewBox := previewBorder.Render(preview)198203 199- // Join side by side with a gap.200- spacer := " "201- body := lipgloss.JoinHorizontal(lipgloss.Top, listView, spacer, previewBox)204+ body := lipgloss.JoinHorizontal(lipgloss.Top, listView, " ", previewBox)202205 203206 panel := ui.Panel(204207 "Select Theme",@@ -5,6 +5,7 @@ import (
55 "testing"66 77 tea "charm.land/bubbletea/v2"8+ "github.com/FelineStateMachine/puzzletea/store"89 "github.com/FelineStateMachine/puzzletea/ui"910 )1011 @@ -38,3 +39,27 @@ func TestPlayMenuViewIncludesNotice(t *testing.T) {
3839 t.Fatalf("viewContent() missing notice, got %q", got)3940 }4041 }42+43+func TestContinueViewStaysAtNaturalTableWidth(t *testing.T) {44+ s := openAppTestStore(t)45+ if err := s.CreateGame(&store.GameRecord{46+ Name: "shadow-trail",47+ GameType: "Sudoku",48+ Mode: "Medium",49+ Status: store.StatusInProgress,50+ }); err != nil {51+ t.Fatal(err)52+ }53+ table, games := ui.InitContinueTable(s, 30)54+ m := model{55+ state: continueView,56+ width: 140,57+ height: 30,58+ cont: continueState{table: table, games: games},59+ }60+ m = m.resizeActiveScreen()61+62+ if got, want := m.cont.table.Width(), ui.ContinueTableWidth(); got != want {63+ t.Fatalf("continue table width = %d, want %d", got, want)64+ }65+}@@ -158,9 +158,8 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
158158 }159159 160160 rng := weekly.RNG(info.Year, info.Week, info.Index)161- ctx, jobID := m.beginSpawnContext()162161 infoCopy := info163- m.session.spawn = &spawnRequest{162+ cmd := newSessionController(&m).startSeededSpawn(spawner, rng, spawnRequest{164163 source: spawnSourceWeekly,165164 name: row.Name,166165 gameType: gameType,@@ -169,10 +168,8 @@ func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {
169168 returnState: weeklyView,170169 exitState: weeklyView,171170 weeklyInfo: &infoCopy,172- }173- m.state = generatingView174- m = m.clearNotice()175- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))171+ })172+ return m, cmd176173 }177174 178175 func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {@@ -13,6 +13,21 @@ import (
1313 // MaxTableRows is the maximum number of rows visible in the continue table.1414 const MaxTableRows = 201515 16+const (17+ continueTableWidth = 10418+ weeklyTableWidth = 7119+)20+21+// ContinueTableWidth is the natural width of the saved-games table.22+func ContinueTableWidth() int {23+ return continueTableWidth24+}25+26+// WeeklyTableWidth is the natural width of the weekly browser table.27+func WeeklyTableWidth() int {28+ return weeklyTableWidth29+}30+1631 // FormatStatus converts a GameStatus enum to a human-readable display string.1732 func FormatStatus(s store.GameStatus) string {1833 switch s {@@ -55,19 +70,12 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
5570 }5671 }5772 58- tableWidth := 059- for _, c := range columns {60- tableWidth += c.Width61- }62- // Account for column gaps (2 chars between each column).63- tableWidth += (len(columns) - 1) * 264-6573 visibleRows := min(len(rows), MaxTableRows)6674 t := table.New(6775 table.WithColumns(columns),6876 table.WithRows(rows),6977 table.WithFocused(true),70- table.WithWidth(tableWidth),78+ table.WithWidth(continueTableWidth),7179 table.WithHeight(min(max(height-2, 1), visibleRows)),7280 )7381 t.SetStyles(defaultTableStyles())@@ -85,18 +93,12 @@ func InitWeeklyTable(rows []table.Row, height int) table.Model {
8593 {Title: "Status", Width: 12},8694 }8795 88- tableWidth := 089- for _, c := range columns {90- tableWidth += c.Width91- }92- tableWidth += (len(columns) - 1) * 293-9496 tableHeight := min(max(height-2, 1), MaxTableRows)9597 t := table.New(9698 table.WithColumns(columns),9799 table.WithRows(rows),98100 table.WithFocused(true),99- table.WithWidth(tableWidth),101+ table.WithWidth(weeklyTableWidth),100102 table.WithHeight(tableHeight),101103 )102104 t.SetStyles(defaultTableStyles())