clone

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

Merge branch 'feat/app-decomposition'

Dami committed ded640c
commit
ded640c8792e99517e56e4456167741daa373265
parent
90a8a2ade47e926c7cf5489e7cb672b068cb4eb7
parent
dfb0602199a7d7093f3fa7d311de3e88864bfa71
author
Dami <dami.etoile@gmail.com>
304 files changed, +1002 -854
Mapp/export.go332+++++++++++++++++-----------
Mapp/export_test.go91++++----
Mapp/handle_daily.go2+-
Mapp/handle_game.go50----
Mapp/handle_help.go7+-
Mapp/handle_seed.go2+-
Mapp/handle_stats.go3+-
Mapp/handle_theme.go5+-
Mapp/help_layout_test.go8+-
Mapp/model.go34+--
Mapp/notice.go4-
Mapp/screen_core.go357+++++++++++++++++++-----------
Mapp/screen_menu.go24--
Mapp/screen_navigation.go121++--------
Mapp/screen_support.go83+------
Mapp/seed_input.go27+-
Mapp/session_controller.go2+
Mapp/update.go110++-------
Mapp/update_test.go35++-
Mapp/view.go42++--
Rapp/state_view.go → app/view_helpers.go24+-
Mapp/view_test.go1+
Mapp/weekly.go33++-
Marchitecture_test.go10+-
Mcmd/export.go2+-
Mcmd/export_pdf.go6+-
Mcmd/export_pdf_test.go2+-
Mcmd/export_test.go2+-
Mcmd/new_export.go6+-
Mcmd/new_export_test.go2+-
Mcmd/test.go2+-
Rbuiltinprint/register.go → export/builtinprint/register.go2+-
Rbuiltinprint/register_test.go → export/builtinprint/register_test.go2+-
Rpackexport/packexport_test.go → export/pack/packexport_test.go2+-
Rpackexport/render.go → export/pack/render.go2+-
Rpackexport/runtime.go → export/pack/runtime.go2+-
Rpackexport/spec.go → export/pack/spec.go4+-
Rpdfexport/font.go → export/pdf/font.go0
Rpdfexport/font_test.go → export/pdf/font_test.go0
Rpdfexport/fonts.go → export/pdf/fonts.go0
Rpdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Bold.ttf0
Rpdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt → export/pdf/fonts/AtkinsonHyperlegibleNext-OFL.txt0
Rpdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Regular.ttf0
Rpdfexport/fonts/DMSerifDisplay-OFL.txt → export/pdf/fonts/DMSerifDisplay-OFL.txt0
Rpdfexport/fonts/DMSerifDisplay-Regular.ttf → export/pdf/fonts/DMSerifDisplay-Regular.ttf0
Rpdfexport/jsonl.go → export/pdf/jsonl.go0
Rpdfexport/jsonl_test.go → export/pdf/jsonl_test.go0
Rpdfexport/normalize.go → export/pdf/normalize.go0
Rpdfexport/order.go → export/pdf/order.go0
Rpdfexport/order_test.go → export/pdf/order_test.go0
Rpdfexport/parse_fillomino.go → export/pdf/parse_fillomino.go0
Rpdfexport/parse_hashi.go → export/pdf/parse_hashi.go0
Rpdfexport/parse_helpers.go → export/pdf/parse_helpers.go0
Rpdfexport/parse_hitori.go → export/pdf/parse_hitori.go0
Rpdfexport/parse_netwalk.go → export/pdf/parse_netwalk.go0
Rpdfexport/parse_nonogram.go → export/pdf/parse_nonogram.go0
Rpdfexport/parse_nurikabe.go → export/pdf/parse_nurikabe.go0
Rpdfexport/parse_rippleeffect.go → export/pdf/parse_rippleeffect.go0
Rpdfexport/parse_shikaku.go → export/pdf/parse_shikaku.go0
Rpdfexport/parse_sudoku.go → export/pdf/parse_sudoku.go0
Rpdfexport/parse_takuzu.go → export/pdf/parse_takuzu.go0
Rpdfexport/parse_takuzuplus.go → export/pdf/parse_takuzuplus.go0
Rpdfexport/parse_wordsearch.go → export/pdf/parse_wordsearch.go0
Rpdfexport/print_adapter.go → export/pdf/print_adapter.go0
Rpdfexport/printdata_test.go → export/pdf/printdata_test.go0
Rpdfexport/render.go → export/pdf/render.go0
Rpdfexport/render_cover.go → export/pdf/render_cover.go0
Rpdfexport/render_cover_test.go → export/pdf/render_cover_test.go0
Rpdfexport/render_difficulty.go → export/pdf/render_difficulty.go0
Rpdfexport/render_difficulty_test.go → export/pdf/render_difficulty_test.go0
Rpdfexport/render_error_test.go → export/pdf/render_error_test.go0
Rpdfexport/render_instructions.go → export/pdf/render_instructions.go0
Rpdfexport/render_instructions_test.go → export/pdf/render_instructions_test.go0
Rpdfexport/render_kit.go → export/pdf/render_kit.go0
Rpdfexport/render_layout_test.go → export/pdf/render_layout_test.go0
Rpdfexport/render_metadata.go → export/pdf/render_metadata.go0
Rpdfexport/render_plan.go → export/pdf/render_plan.go0
Rpdfexport/render_title.go → export/pdf/render_title.go0
Rpdfexport/render_title_test.go → export/pdf/render_title_test.go0
Rpdfexport/render_tokens.go → export/pdf/render_tokens.go0
Rpdfexport/types.go → export/pdf/types.go0
Mgameentry/gameentry.go2+-
Rfillomino/README.md → games/fillomino/README.md0
Rfillomino/export.go → games/fillomino/export.go0
Rfillomino/fillomino_test.go → games/fillomino/fillomino_test.go0
Rfillomino/gamemode.go → games/fillomino/gamemode.go0
Rfillomino/generator.go → games/fillomino/generator.go0
Rfillomino/grid.go → games/fillomino/grid.go0
Rfillomino/help.md → games/fillomino/help.md0
Rfillomino/keys.go → games/fillomino/keys.go0
Rfillomino/model.go → games/fillomino/model.go0
Rfillomino/mouse.go → games/fillomino/mouse.go0
Rfillomino/print_adapter.go → games/fillomino/print_adapter.go2+-
Rfillomino/print_adapter_test.go → games/fillomino/print_adapter_test.go0
Rfillomino/solver.go → games/fillomino/solver.go0
Rfillomino/style.go → games/fillomino/style.go0
Rfillomino/testdata/visual_states.jsonl → games/fillomino/testdata/visual_states.jsonl0
Rhashiwokakero/README.md → games/hashiwokakero/README.md0
Rhashiwokakero/benchmark_test.go → games/hashiwokakero/benchmark_test.go0
Rhashiwokakero/export.go → games/hashiwokakero/export.go0
Rhashiwokakero/gamemode.go → games/hashiwokakero/gamemode.go0
Rhashiwokakero/generation_invariants_test.go → games/hashiwokakero/generation_invariants_test.go0
Rhashiwokakero/generator.go → games/hashiwokakero/generator.go0
Rhashiwokakero/hashiwokakero_test.go → games/hashiwokakero/hashiwokakero_test.go0
Rhashiwokakero/help.md → games/hashiwokakero/help.md0
Rhashiwokakero/island.go → games/hashiwokakero/island.go0
Rhashiwokakero/keys.go → games/hashiwokakero/keys.go0
Rhashiwokakero/model.go → games/hashiwokakero/model.go0
Rhashiwokakero/mouse.go → games/hashiwokakero/mouse.go0
Rhashiwokakero/print_adapter.go → games/hashiwokakero/print_adapter.go2+-
Rhashiwokakero/style.go → games/hashiwokakero/style.go0
Rhashiwokakero/testdata/visual_states.jsonl → games/hashiwokakero/testdata/visual_states.jsonl0
Rhitori/README.md → games/hitori/README.md0
Rhitori/benchmark_test.go → games/hitori/benchmark_test.go0
Rhitori/export.go → games/hitori/export.go0
Rhitori/gamemode.go → games/hitori/gamemode.go0
Rhitori/generator.go → games/hitori/generator.go0
Rhitori/grid.go → games/hitori/grid.go0
Rhitori/help.md → games/hitori/help.md0
Rhitori/hitori_test.go → games/hitori/hitori_test.go0
Rhitori/keys.go → games/hitori/keys.go0
Rhitori/model.go → games/hitori/model.go0
Rhitori/mouse.go → games/hitori/mouse.go0
Rhitori/print_adapter.go → games/hitori/print_adapter.go2+-
Rhitori/solver.go → games/hitori/solver.go0
Rhitori/style.go → games/hitori/style.go0
Rhitori/testdata/visual_states.jsonl → games/hitori/testdata/visual_states.jsonl0
Rlightsout/README.md → games/lightsout/README.md0
Rlightsout/benchmark_test.go → games/lightsout/benchmark_test.go0
Rlightsout/export.go → games/lightsout/export.go0
Rlightsout/gamemode.go → games/lightsout/gamemode.go0
Rlightsout/generator.go → games/lightsout/generator.go0
Rlightsout/help.md → games/lightsout/help.md0
Rlightsout/keys.go → games/lightsout/keys.go0
Rlightsout/lightsout_test.go → games/lightsout/lightsout_test.go0
Rlightsout/model.go → games/lightsout/model.go0
Rlightsout/style.go → games/lightsout/style.go0
Rlightsout/testdata/visual_states.jsonl → games/lightsout/testdata/visual_states.jsonl0
Rnetwalk/README.md → games/netwalk/README.md0
Rnetwalk/export.go → games/netwalk/export.go0
Rnetwalk/gamemode.go → games/netwalk/gamemode.go0
Rnetwalk/generator.go → games/netwalk/generator.go0
Rnetwalk/grid.go → games/netwalk/grid.go0
Rnetwalk/help.md → games/netwalk/help.md0
Rnetwalk/keys.go → games/netwalk/keys.go0
Rnetwalk/model.go → games/netwalk/model.go0
Rnetwalk/netwalk_test.go → games/netwalk/netwalk_test.go0
Rnetwalk/print_adapter.go → games/netwalk/print_adapter.go2+-
Rnetwalk/style.go → games/netwalk/style.go0
Rnetwalk/testdata/visual_states.jsonl → games/netwalk/testdata/visual_states.jsonl0
Rnonogram/README.md → games/nonogram/README.md0
Rnonogram/dynamic_grid_test.go → games/nonogram/dynamic_grid_test.go0
Rnonogram/export.go → games/nonogram/export.go0
Rnonogram/gamemode.go → games/nonogram/gamemode.go0
Rnonogram/generator.go → games/nonogram/generator.go0
Rnonogram/grid.go → games/nonogram/grid.go0
Rnonogram/help.md → games/nonogram/help.md0
Rnonogram/keys.go → games/nonogram/keys.go0
Rnonogram/model.go → games/nonogram/model.go0
Rnonogram/mouse.go → games/nonogram/mouse.go0
Rnonogram/nonogram_test.go → games/nonogram/nonogram_test.go0
Rnonogram/print_adapter.go → games/nonogram/print_adapter.go2+-
Rnonogram/print_adapter_test.go → games/nonogram/print_adapter_test.go2+-
Rnonogram/style.go → games/nonogram/style.go0
Rnonogram/testdata/visual_states.jsonl → games/nonogram/testdata/visual_states.jsonl0
Rnonogram/tomography.go → games/nonogram/tomography.go0
Rnurikabe/README.md → games/nurikabe/README.md0
Rnurikabe/export.go → games/nurikabe/export.go0
Rnurikabe/gamemode.go → games/nurikabe/gamemode.go0
Rnurikabe/generator.go → games/nurikabe/generator.go0
Rnurikabe/grid.go → games/nurikabe/grid.go0
Rnurikabe/help.md → games/nurikabe/help.md0
Rnurikabe/keys.go → games/nurikabe/keys.go0
Rnurikabe/model.go → games/nurikabe/model.go0
Rnurikabe/mouse.go → games/nurikabe/mouse.go0
Rnurikabe/nurikabe_test.go → games/nurikabe/nurikabe_test.go0
Rnurikabe/print_adapter.go → games/nurikabe/print_adapter.go2+-
Rnurikabe/solver.go → games/nurikabe/solver.go0
Rnurikabe/style.go → games/nurikabe/style.go0
Rnurikabe/testdata/visual_states.jsonl → games/nurikabe/testdata/visual_states.jsonl0
Rrippleeffect/README.md → games/rippleeffect/README.md6+-
Rrippleeffect/export.go → games/rippleeffect/export.go0
Rrippleeffect/gamemode.go → games/rippleeffect/gamemode.go57+++--
Rrippleeffect/generator.go → games/rippleeffect/generator.go167+++++++++++++-
Rrippleeffect/geometry.go → games/rippleeffect/geometry.go0
Rrippleeffect/grid.go → games/rippleeffect/grid.go0
Rrippleeffect/help.md → games/rippleeffect/help.md0
Rrippleeffect/keys.go → games/rippleeffect/keys.go0
Rrippleeffect/model.go → games/rippleeffect/model.go0
Rrippleeffect/print_adapter.go → games/rippleeffect/print_adapter.go2+-
Rrippleeffect/rippleeffect_test.go → games/rippleeffect/rippleeffect_test.go97++++++++
Rrippleeffect/solver.go → games/rippleeffect/solver.go0
Rrippleeffect/style.go → games/rippleeffect/style.go0
Rrippleeffect/testdata/visual_states.jsonl → games/rippleeffect/testdata/visual_states.jsonl0
Rshikaku/README.md → games/shikaku/README.md0
Rshikaku/benchmark_test.go → games/shikaku/benchmark_test.go0
Rshikaku/export.go → games/shikaku/export.go0
Rshikaku/gamemode.go → games/shikaku/gamemode.go0
Rshikaku/generation_invariants_test.go → games/shikaku/generation_invariants_test.go0
Rshikaku/generator.go → games/shikaku/generator.go0
Rshikaku/help.md → games/shikaku/help.md0
Rshikaku/keys.go → games/shikaku/keys.go0
Rshikaku/model.go → games/shikaku/model.go0
Rshikaku/mouse.go → games/shikaku/mouse.go0
Rshikaku/print_adapter.go → games/shikaku/print_adapter.go2+-
Rshikaku/puzzle.go → games/shikaku/puzzle.go0
Rshikaku/shikaku_test.go → games/shikaku/shikaku_test.go0
Rshikaku/style.go → games/shikaku/style.go0
Rshikaku/testdata/visual_states.jsonl → games/shikaku/testdata/visual_states.jsonl0
Rspellpuzzle/README.md → games/spellpuzzle/README.md0
Rspellpuzzle/board.go → games/spellpuzzle/board.go0
Rspellpuzzle/export.go → games/spellpuzzle/export.go0
Rspellpuzzle/gamemode.go → games/spellpuzzle/gamemode.go0
Rspellpuzzle/generator.go → games/spellpuzzle/generator.go0
Rspellpuzzle/help.md → games/spellpuzzle/help.md0
Rspellpuzzle/keys.go → games/spellpuzzle/keys.go0
Rspellpuzzle/lexicon.go → games/spellpuzzle/lexicon.go0
Rspellpuzzle/model.go → games/spellpuzzle/model.go0
Rspellpuzzle/print_adapter.go → games/spellpuzzle/print_adapter.go2+-
Rspellpuzzle/print_adapter_test.go → games/spellpuzzle/print_adapter_test.go2+-
Rspellpuzzle/spellpuzzle_test.go → games/spellpuzzle/spellpuzzle_test.go0
Rspellpuzzle/style.go → games/spellpuzzle/style.go0
Rspellpuzzle/twl06.txt → games/spellpuzzle/twl06.txt0
Rsudoku/README.md → games/sudoku/README.md0
Rsudoku/benchmark_test.go → games/sudoku/benchmark_test.go0
Rsudoku/cell.go → games/sudoku/cell.go0
Rsudoku/dynamic_board_test.go → games/sudoku/dynamic_board_test.go0
Rsudoku/export.go → games/sudoku/export.go0
Rsudoku/gamemode.go → games/sudoku/gamemode.go0
Rsudoku/generator.go → games/sudoku/generator.go0
Rsudoku/help.md → games/sudoku/help.md0
Rsudoku/keys.go → games/sudoku/keys.go0
Rsudoku/model.go → games/sudoku/model.go0
Rsudoku/mouse.go → games/sudoku/mouse.go0
Rsudoku/print_adapter.go → games/sudoku/print_adapter.go2+-
Rsudoku/style.go → games/sudoku/style.go0
Rsudoku/sudoku_test.go → games/sudoku/sudoku_test.go0
Rsudoku/testdata/visual_states.jsonl → games/sudoku/testdata/visual_states.jsonl0
Rsudoku/uniqueness_test.go → games/sudoku/uniqueness_test.go0
Rsudokurgb/README.md → games/sudokurgb/README.md0
Rsudokurgb/analysis.go → games/sudokurgb/analysis.go0
Rsudokurgb/cell.go → games/sudokurgb/cell.go0
Rsudokurgb/dynamic_board_test.go → games/sudokurgb/dynamic_board_test.go0
Rsudokurgb/export.go → games/sudokurgb/export.go0
Rsudokurgb/gamemode.go → games/sudokurgb/gamemode.go0
Rsudokurgb/generator.go → games/sudokurgb/generator.go0
Rsudokurgb/help.md → games/sudokurgb/help.md0
Rsudokurgb/keys.go → games/sudokurgb/keys.go0
Rsudokurgb/model.go → games/sudokurgb/model.go0
Rsudokurgb/mouse.go → games/sudokurgb/mouse.go0
Rsudokurgb/print_adapter.go → games/sudokurgb/print_adapter.go2+-
Rsudokurgb/style.go → games/sudokurgb/style.go0
Rsudokurgb/sudokurgb_test.go → games/sudokurgb/sudokurgb_test.go0
Rsudokurgb/testdata/visual_states.jsonl → games/sudokurgb/testdata/visual_states.jsonl0
Rtakuzu/README.md → games/takuzu/README.md0
Rtakuzu/benchmark_test.go → games/takuzu/benchmark_test.go0
Rtakuzu/export.go → games/takuzu/export.go0
Rtakuzu/gamemode.go → games/takuzu/gamemode.go0
Rtakuzu/generator.go → games/takuzu/generator.go0
Rtakuzu/grid.go → games/takuzu/grid.go0
Rtakuzu/help.md → games/takuzu/help.md0
Rtakuzu/keys.go → games/takuzu/keys.go0
Rtakuzu/model.go → games/takuzu/model.go0
Rtakuzu/mouse.go → games/takuzu/mouse.go0
Rtakuzu/print_adapter.go → games/takuzu/print_adapter.go2+-
Rtakuzu/print_adapter_test.go → games/takuzu/print_adapter_test.go2+-
Rtakuzu/style.go → games/takuzu/style.go0
Rtakuzu/takuzu_test.go → games/takuzu/takuzu_test.go0
Rtakuzu/testdata/visual_states.jsonl → games/takuzu/testdata/visual_states.jsonl0
Rtakuzuplus/README.md → games/takuzuplus/README.md0
Rtakuzuplus/export.go → games/takuzuplus/export.go0
Rtakuzuplus/gamemode.go → games/takuzuplus/gamemode.go0
Rtakuzuplus/generator.go → games/takuzuplus/generator.go2+-
Rtakuzuplus/grid.go → games/takuzuplus/grid.go2+-
Rtakuzuplus/help.md → games/takuzuplus/help.md0
Rtakuzuplus/keys.go → games/takuzuplus/keys.go0
Rtakuzuplus/logic.go → games/takuzuplus/logic.go2+-
Rtakuzuplus/model.go → games/takuzuplus/model.go2+-
Rtakuzuplus/mouse.go → games/takuzuplus/mouse.go0
Rtakuzuplus/print_adapter.go → games/takuzuplus/print_adapter.go4+-
Rtakuzuplus/profile.go → games/takuzuplus/profile.go0
Rtakuzuplus/style.go → games/takuzuplus/style.go0
Rtakuzuplus/takuzuplus_test.go → games/takuzuplus/takuzuplus_test.go0
Rtakuzuplus/testdata/visual_states.jsonl → games/takuzuplus/testdata/visual_states.jsonl0
Rwordsearch/README.md → games/wordsearch/README.md0
Rwordsearch/benchmark_test.go → games/wordsearch/benchmark_test.go0
Rwordsearch/export.go → games/wordsearch/export.go0
Rwordsearch/gamemode.go → games/wordsearch/gamemode.go0
Rwordsearch/generator.go → games/wordsearch/generator.go0
Rwordsearch/grid.go → games/wordsearch/grid.go0
Rwordsearch/help.md → games/wordsearch/help.md0
Rwordsearch/keys.go → games/wordsearch/keys.go0
Rwordsearch/model.go → games/wordsearch/model.go0
Rwordsearch/mouse.go → games/wordsearch/mouse.go0
Rwordsearch/print_adapter.go → games/wordsearch/print_adapter.go2+-
Rwordsearch/style.go → games/wordsearch/style.go0
Rwordsearch/word.go → games/wordsearch/word.go0
Rwordsearch/words_dict.go → games/wordsearch/words_dict.go0
Rwordsearch/wordsearch_test.go → games/wordsearch/wordsearch_test.go0
Minternal/testjsonl/records.go2+-
Mregistry/registry.go30+-
Mregistry/registry_test.go4+-
Mresolve/resolve_test.go2+-
Msession/session_test.go2+-
M app/export.go +200 -132
@@ -10,8 +10,8 @@ import (
1010 "strings"1111 1212 "github.com/FelineStateMachine/puzzletea/config"13+ "github.com/FelineStateMachine/puzzletea/export/pack"1314 puzzlegame "github.com/FelineStateMachine/puzzletea/game"14- "github.com/FelineStateMachine/puzzletea/packexport"1515 "github.com/FelineStateMachine/puzzletea/puzzle"1616 "github.com/FelineStateMachine/puzzletea/theme"1717 "github.com/FelineStateMachine/puzzletea/ui"
@@ -24,6 +24,24 @@ import (
2424 "charm.land/lipgloss/v2"2525 )2626 27+type exportModel struct {28+ focus exportFocus29+ values exportFormValues30+ titleInput textinput.Model31+ headerInput textarea.Model32+ advertInput textinput.Model33+ volumeInput textinput.Model34+ seedInput textinput.Model35+ pdfPathInput textinput.Model36+ jsonlPathInput textinput.Model37+ cards []exportGameCard38+ cardIndex int39+ bucketIndex int40+ cardRowOffset int41+ width int42+ height int43+}44+2745 type exportFocus int2846 2947 const (
@@ -109,35 +127,27 @@ type exportCompleteMsg struct {
109127 err error110128 }111129 112-type exportScreen struct {113- width int114- height int115- export exportState116-}117-118-func (s exportScreen) State() viewState { return exportView }119-120-func (s exportScreen) Resize(width, height int) screenModel {121- s.width = width122- s.height = height123- contentWidth, contentHeight := exportContentBounds(width, height)124- s.export.resize(contentWidth)125- s.export.ensureCardSelectionVisible(contentWidth, contentHeight)126- return s127-}130+// exportSubmitCmd and exportBackCmd are sentinel commands emitted by131+// exportModel.Update to communicate submit/cancel to the root model.132+var (133+ exportSubmitCmd = func() tea.Msg { return exportSubmitAction{} }134+ exportBackCmd = func() tea.Msg { return backAction{target: mainMenuView} }135+)128136 129-func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {137+// Update handles all export form input and returns sentinel commands for138+// submit (exportSubmitCmd) and cancel (exportBackCmd).139+func (s *exportModel) Update(msg tea.Msg) (exportModel, tea.Cmd) {130140 if clickMsg, ok := msg.(tea.MouseClickMsg); ok {131141 contentWidth, contentHeight := exportContentBounds(s.width, s.height)132- if cmd, action, handled := s.export.handleMouseClick(clickMsg, s.width, s.height, contentWidth, contentHeight); handled {133- return s, cmd, action142+ if cmd, handled := s.handleMouseClick(clickMsg, s.width, s.height, contentWidth, contentHeight); handled {143+ return *s, cmd134144 }135- return s, nil, nil145+ return *s, nil136146 }137147 if wheelMsg, ok := msg.(tea.MouseWheelMsg); ok {138148 contentWidth, contentHeight := exportContentBounds(s.width, s.height)139- if handled := s.export.handleMouseWheel(wheelMsg, s.width, s.height, contentWidth, contentHeight); handled {140- return s, nil, nil149+ if handled := s.handleMouseWheel(wheelMsg, s.width, s.height, contentWidth, contentHeight); handled {150+ return *s, nil141151 }142152 }143153
@@ -145,39 +155,66 @@ func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
145155 if ok {146156 switch {147157 case key.Matches(keyMsg, rootKeys.Escape):148- return s, nil, backAction{target: mainMenuView}158+ return *s, exportBackCmd149159 case keyMsg.String() == "tab":150- cmd := s.export.moveFocus(1)151- return s, cmd, nil160+ cmd := s.moveFocus(1)161+ return *s, cmd152162 case keyMsg.String() == "shift+tab":153- cmd := s.export.moveFocus(-1)154- return s, cmd, nil163+ cmd := s.moveFocus(-1)164+ return *s, cmd155165 case key.Matches(keyMsg, rootKeys.Enter):156- if s.export.focus == exportFocusSubmit {157- return s, nil, exportSubmitAction{}166+ if s.focus == exportFocusSubmit {167+ return *s, exportSubmitCmd158168 }159- if s.export.focus == exportFocusHeader {169+ if s.focus == exportFocusHeader {160170 break161171 }162- if s.export.focus == exportFocusLayout {163- s.export.cycleLayout(1)164- return s, nil, nil172+ if s.focus == exportFocusLayout {173+ s.cycleLayout(1)174+ return *s, nil165175 }166- if s.export.focus == exportFocusJSONLToggle {167- s.export.toggleJSONL()168- return s, nil, nil176+ if s.focus == exportFocusJSONLToggle {177+ s.toggleJSONL()178+ return *s, nil169179 }170- cmd := s.export.moveFocus(1)171- return s, cmd, nil180+ cmd := s.moveFocus(1)181+ return *s, cmd172182 }173183 174184 contentWidth, contentHeight := exportContentBounds(s.width, s.height)175- if handled := s.export.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {176- return s, nil, nil185+ if handled := s.handleNavigationKey(keyMsg, contentWidth, contentHeight); handled {186+ return *s, nil177187 }178188 }179189 180- cmd := s.export.updateFocusedInput(msg)190+ cmd := s.updateFocusedInput(msg)191+ return *s, cmd192+}193+194+type exportScreen struct {195+ width int196+ height int197+ export exportModel198+ // job lifecycle — kept here so exportModel is pure form state199+ running bool200+ jobID int64201+ cancel context.CancelFunc202+}203+204+func (s exportScreen) Resize(width, height int) screenModel {205+ s.width = width206+ s.height = height207+ s.export.width = width208+ s.export.height = height209+ contentWidth, contentHeight := exportContentBounds(width, height)210+ s.export.resize(contentWidth)211+ s.export.ensureCardSelectionVisible(contentWidth, contentHeight)212+ return s213+}214+215+func (s exportScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {216+ next, cmd := s.export.Update(msg)217+ s.export = next181218 return s, cmd, nil182219 }183220
@@ -193,20 +230,12 @@ func (s exportScreen) View(notice noticeState) string {
193230 )194231 }195232 196-func (s exportScreen) Apply(m model) model {197- m.state = exportView198- m.export = s.export199- return m200-}201-202233 type exportRunningScreen struct {203234 width int204235 height int205236 spinner spinner.Model206237 }207238 208-func (s exportRunningScreen) State() viewState { return exportRunningView }209-210239 func (s exportRunningScreen) Resize(width, height int) screenModel {211240 s.width = width212241 s.height = height
@@ -225,26 +254,37 @@ func (s exportRunningScreen) View(notice noticeState) string {
225254 return ui.CenterView(s.width, s.height, box)226255 }227256 228-func (s exportRunningScreen) Apply(m model) model {229- m.state = exportRunningView230- m.spinner = s.spinner231- return m257+func (m model) activeExportScreen() (exportScreen, bool) {258+ s, ok := m.screens[exportView].(exportScreen)259+ return s, ok232260 }233261 234-func (m model) handleExportEnter() (tea.Model, tea.Cmd) {235- if !m.export.initialized {262+func (m model) handleExportEnter() (model, tea.Cmd) {263+ if m.screens == nil {264+ m.screens = make(map[viewState]screenModel)265+ }266+ if _, exists := m.screens[exportView]; !exists {236267 spec := m.initialExportSpec()237- m.export = buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width)268+ es := exportScreen{269+ width: m.width,270+ height: m.height,271+ export: buildInitialExportState(exportValuesFromSpec(spec), buildExportCardsFromSpec(spec), m.width),272+ }273+ m.screens[exportView] = es.Resize(m.width, m.height)238274 } else {239- m.export.resize(m.width)275+ m.screens[exportView] = m.screens[exportView].Resize(m.width, m.height)240276 }241277 m.state = exportView242278 m = m.clearNotice()243- return m.resizeActiveScreen(), nil279+ return m, nil244280 }245281 246-func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {247- spec, cfg := m.export.toSpecAndConfig()282+func (m model) handleExportSubmit() (model, tea.Cmd) {283+ es, ok := m.activeExportScreen()284+ if !ok {285+ return m, nil286+ }287+ spec, cfg := es.export.toSpecAndConfig()248288 if err := packexport.ValidateSpec(spec); err != nil {249289 return m.setErrorf("Could not start export: %v", err), nil250290 }
@@ -258,17 +298,18 @@ func (m model) handleExportSubmit() (tea.Model, tea.Cmd) {
258298 return m, cmd259299 }260300 261-func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd) {262- if msg.jobID != m.export.jobID {301+func (m model) handleExportComplete(msg exportCompleteMsg) (model, tea.Cmd) {302+ es, ok := m.activeExportScreen()303+ if !ok || msg.jobID != es.jobID {263304 return m, nil264305 }265306 266- m.export.running = false267- if m.export.cancel != nil {268- m.export.cancel()269- m.export.cancel = nil307+ if es.cancel != nil {308+ es.cancel()309+ es.cancel = nil270310 }271-311+ es.running = false312+ m.screens[exportView] = es272313 m.state = exportView273314 274315 if msg.err != nil {
@@ -283,25 +324,33 @@ func (m model) handleExportComplete(msg exportCompleteMsg) (tea.Model, tea.Cmd)
283324 }284325 285326 func (m *model) cancelActiveExport() {286- if m.export.cancel != nil {287- m.export.cancel()288- m.export.cancel = nil327+ es, ok := m.screens[exportView].(exportScreen)328+ if !ok {329+ return289330 }290- if m.export.running {291- m.export.jobID++331+ if es.cancel != nil {332+ es.cancel()333+ es.cancel = nil292334 }293- m.export.running = false335+ if es.running {336+ es.jobID++337+ }338+ es.running = false339+ m.screens[exportView] = es294340 }295341 296342 func (m *model) startExport(spec packexport.Spec) tea.Cmd {297343 m.cancelActiveExport()298- m.export.jobID++299- jobID := m.export.jobID344+ es, _ := m.screens[exportView].(exportScreen)345+ es.jobID++346+ jobID := es.jobID300347 ctx, cancel := context.WithCancel(context.Background())301- m.export.cancel = cancel302- m.export.running = true348+ es.cancel = cancel349+ es.running = true350+ m.screens[exportView] = es303351 m.state = exportRunningView304352 *m = m.clearNotice()353+ *m = m.initScreen(exportRunningView)305354 return tea.Batch(m.spinner.Tick, exportCmd(ctx, jobID, spec))306355 }307356
@@ -312,13 +361,13 @@ func exportCmd(ctx context.Context, jobID int64, spec packexport.Spec) tea.Cmd {
312361 }313362 }314363 315-func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportState {364+func buildInitialExportState(values exportFormValues, cards []exportGameCard, width int) exportModel {316365 contentWidth, _ := exportContentBounds(width, 0)317- state := exportState{318- initialized: true,319- focus: exportFocusCards,320- values: values,321- cards: cards,366+ state := exportModel{367+ focus: exportFocusCards,368+ values: values,369+ cards: cards,370+ width: width,322371 }323372 state.initInputs(contentWidth)324373 return state
@@ -444,7 +493,7 @@ func bucketIndexForMode(index, total int) int {
444493 return index * 3 / total445494 }446495 447-func (s *exportState) initInputs(width int) {496+func (s *exportModel) initInputs(width int) {448497 s.titleInput = newExportInput(s.values.Title, "Sampler title")449498 s.headerInput = newExportTextarea(s.values.Header, "Short intro")450499 s.advertInput = newExportInput(s.values.Advert, "Footer copy")
@@ -511,7 +560,7 @@ func styleExportTextarea(input *textarea.Model) {
511560 input.SetStyles(styles)512561 }513562 514-func (s *exportState) resize(width int) {563+func (s *exportModel) resize(width int) {515564 styleExportInput(&s.titleInput)516565 styleExportTextarea(&s.headerInput)517566 styleExportInput(&s.advertInput)
@@ -531,7 +580,7 @@ func (s *exportState) resize(width int) {
531580 s.jsonlPathInput.SetWidth(max(26, min(width-20, 72)))532581 }533582 534-func (s *exportState) visibleFocuses() []exportFocus {583+func (s *exportModel) visibleFocuses() []exportFocus {535584 focuses := []exportFocus{536585 exportFocusTitle,537586 exportFocusAdvert,
@@ -549,7 +598,7 @@ func (s *exportState) visibleFocuses() []exportFocus {
549598 return focuses550599 }551600 552-func (s *exportState) moveFocus(delta int) tea.Cmd {601+func (s *exportModel) moveFocus(delta int) tea.Cmd {553602 focuses := s.visibleFocuses()554603 current := 0555604 for i, focus := range focuses {
@@ -563,10 +612,16 @@ func (s *exportState) moveFocus(delta int) tea.Cmd {
563612 next += len(focuses)564613 }565614 s.focus = focuses[next]615+ switch s.focus {616+ case exportFocusPDFPath:617+ s.pdfPathInput.SetCursor(runeIdxBeforeExt(s.values.PDFOutputPath))618+ case exportFocusJSONLPath:619+ s.jsonlPathInput.SetCursor(runeIdxBeforeExt(s.values.JSONLOutputPath))620+ }566621 return s.applyFocus()567622 }568623 569-func (s *exportState) applyFocus() tea.Cmd {624+func (s *exportModel) applyFocus() tea.Cmd {570625 s.titleInput.Blur()571626 s.headerInput.Blur()572627 s.advertInput.Blur()
@@ -595,7 +650,7 @@ func (s *exportState) applyFocus() tea.Cmd {
595650 }596651 }597652 598-func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {653+func (s *exportModel) updateFocusedInput(msg tea.Msg) tea.Cmd {599654 var cmd tea.Cmd600655 switch s.focus {601656 case exportFocusTitle:
@@ -623,7 +678,7 @@ func (s *exportState) updateFocusedInput(msg tea.Msg) tea.Cmd {
623678 return cmd624679 }625680 626-func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {681+func (s *exportModel) handleNavigationKey(msg tea.KeyPressMsg, width, height int) bool {627682 switch s.focus {628683 case exportFocusLayout:629684 switch msg.String() {
@@ -692,7 +747,7 @@ func (s *exportState) handleNavigationKey(msg tea.KeyPressMsg, width, height int
692747 return false693748 }694749 695-func (s *exportState) moveBucketSelection(delta, width, height int) {750+func (s *exportModel) moveBucketSelection(delta, width, height int) {696751 if len(s.cards) == 0 || delta == 0 {697752 return698753 }
@@ -704,7 +759,7 @@ func (s *exportState) moveBucketSelection(delta, width, height int) {
704759 s.ensureCardSelectionVisible(width, height)705760 }706761 707-func (s *exportState) appendSelectedBucketDigit(raw string) {762+func (s *exportModel) appendSelectedBucketDigit(raw string) {708763 if len(s.cards) == 0 {709764 return710765 }
@@ -719,7 +774,7 @@ func (s *exportState) appendSelectedBucketDigit(raw string) {
719774 card.Buckets[s.bucketIndex] = card.Buckets[s.bucketIndex]*10 + digit720775 }721776 722-func (s *exportState) backspaceSelectedBucket() {777+func (s *exportModel) backspaceSelectedBucket() {723778 if len(s.cards) == 0 {724779 return725780 }
@@ -727,7 +782,7 @@ func (s *exportState) backspaceSelectedBucket() {
727782 card.Buckets[s.bucketIndex] /= 10728783 }729784 730-func (s *exportState) cycleLayout(delta int) {785+func (s *exportModel) cycleLayout(delta int) {731786 index := 0732787 for i, layout := range exportSheetLayouts {733788 if layout == s.values.SheetLayout {
@@ -742,14 +797,14 @@ func (s *exportState) cycleLayout(delta int) {
742797 s.values.SheetLayout = exportSheetLayouts[index]743798 }744799 745-func (s *exportState) toggleJSONL() {800+func (s *exportModel) toggleJSONL() {746801 s.values.JSONLEnabled = !s.values.JSONLEnabled747802 if !s.values.JSONLEnabled && s.focus == exportFocusJSONLPath {748803 s.focus = exportFocusCards749804 }750805 }751806 752-func (s *exportState) adjustSelectedBucket(delta int) {807+func (s *exportModel) adjustSelectedBucket(delta int) {753808 if len(s.cards) == 0 {754809 return755810 }
@@ -775,7 +830,7 @@ func (c exportGameCard) bucketSeeded(index int) bool {
775830 return true776831 }777832 778-func (s *exportState) cardColumns(width int) int {833+func (s *exportModel) cardColumns(width int) int {779834 for cols := exportCardMaxColumns; cols >= 1; cols-- {780835 if exportCardWidth(width, cols) >= exportCardMinWidth {781836 return cols
@@ -784,7 +839,7 @@ func (s *exportState) cardColumns(width int) int {
784839 return 1785840 }786841 787-func (s *exportState) ensureCardSelectionVisible(width, height int) {842+func (s *exportModel) ensureCardSelectionVisible(width, height int) {788843 cols := s.cardColumns(width)789844 if cols <= 0 {790845 return
@@ -801,7 +856,7 @@ func (s *exportState) ensureCardSelectionVisible(width, height int) {
801856 s.cardRowOffset = min(max(s.cardRowOffset, 0), maxRows-1)802857 }803858 804-func (s *exportState) visibleCardRows(width, height int) int {859+func (s *exportModel) visibleCardRows(width, height int) int {805860 if len(s.cards) == 0 {806861 return 1807862 }
@@ -838,14 +893,14 @@ func canFitCardRows(available, cardHeight, rows int) bool {
838893 return available >= required839894 }840895 841-func (s *exportState) view(width, height int) string {896+func (s *exportModel) view(width, height int) string {842897 settings := s.renderSettings(width)843898 summary := s.renderSummary(width)844899 cards := s.renderCards(width, height)845900 return lipgloss.JoinVertical(lipgloss.Left, settings, summary, cards)846901 }847902 848-func (s *exportState) contentMetrics(width, height int) exportContentMetrics {903+func (s *exportModel) contentMetrics(width, height int) exportContentMetrics {849904 settings := s.settingsRects(width)850905 settingsHeight := settings.TotalHeight851906 summaryHeight := lipgloss.Height(s.renderSummary(width))
@@ -887,7 +942,7 @@ func (s *exportState) contentMetrics(width, height int) exportContentMetrics {
887942 }888943 }889944 890-func (s *exportState) settingsRects(width int) exportSettingsRects {945+func (s *exportModel) settingsRects(width int) exportSettingsRects {891946 if width < 72 {892947 return s.stackedSettingsRects(width)893948 }
@@ -929,7 +984,7 @@ func (s *exportState) settingsRects(width int) exportSettingsRects {
929984 return row1930985 }931986 932-func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {987+func (s *exportModel) stackedSettingsRects(width int) exportSettingsRects {933988 y := 0934989 inlineHeight := s.inlineFieldHeight(width)935990 headerHeight := s.headerFieldHeight(width)
@@ -972,17 +1027,17 @@ func (s *exportState) stackedSettingsRects(width int) exportSettingsRects {
9721027 return rects9731028 }9741029 975-func (s *exportState) inlineFieldHeight(width int) int {1030+func (s *exportModel) inlineFieldHeight(width int) int {9761031 input := s.titleInput9771032 return lipgloss.Height(s.renderInlineInputField("Title", &input, false, max(width, 14)))9781033 }9791034 980-func (s *exportState) headerFieldHeight(width int) int {1035+func (s *exportModel) headerFieldHeight(width int) int {9811036 input := s.headerInput9821037 return lipgloss.Height(s.renderTextareaField("Header", &input, false, max(width, 24)))9831038 }9841039 985-func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {1040+func (s *exportModel) cardRects(width, cardsY, cardWidth, cardHeight, visibleRows int) []exportCardRects {9861041 if len(s.cards) == 0 || cardHeight <= 0 {9871042 return nil9881043 }
@@ -1009,7 +1064,7 @@ func (s *exportState) cardRects(width, cardsY, cardWidth, cardHeight, visibleRow
10091064 return layouts10101065 }10111066 1012-func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRect {1067+func (s *exportModel) bucketRectsForCard(index int, rect exportRect) [3]exportRect {10131068 var rects [3]exportRect10141069 card := s.cards[index]10151070 selected := index == s.cardIndex
@@ -1024,7 +1079,7 @@ func (s *exportState) bucketRectsForCard(index int, rect exportRect) [3]exportRe
10241079 return rects10251080 }10261081 1027-func (s *exportState) renderSettings(width int) string {1082+func (s *exportModel) renderSettings(width int) string {10281083 if width < 72 {10291084 return s.renderStackedSettings(width)10301085 }
@@ -1065,7 +1120,7 @@ func (s *exportState) renderSettings(width int) string {
10651120 return lipgloss.JoinVertical(lipgloss.Left, rows...)10661121 }10671122 1068-func (s *exportState) renderStackedSettings(width int) string {1123+func (s *exportModel) renderStackedSettings(width int) string {10691124 rows := []string{10701125 s.renderInlineInputField("Title", &s.titleInput, s.focus == exportFocusTitle, width),10711126 s.renderInlineInputField("Advert", &s.advertInput, s.focus == exportFocusAdvert, width),
@@ -1100,7 +1155,7 @@ func (s *exportState) renderStackedSettings(width int) string {
11001155 return lipgloss.JoinVertical(lipgloss.Left, rows...)11011156 }11021157 1103-func (s *exportState) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {1158+func (s *exportModel) renderTextareaField(label string, input *textarea.Model, focused bool, width int) string {11041159 border := theme.Current().Border11051160 if focused {11061161 border = theme.Current().Accent
@@ -1123,7 +1178,7 @@ func (s *exportState) renderTextareaField(label string, input *textarea.Model, f
11231178 Render(body)11241179 }11251180 1126-func (s *exportState) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {1181+func (s *exportModel) renderInlineInputField(label string, input *textinput.Model, focused bool, width int) string {11271182 border := theme.Current().Border11281183 if focused {11291184 border = theme.Current().Accent
@@ -1148,7 +1203,7 @@ func (s *exportState) renderInlineInputField(label string, input *textinput.Mode
11481203 Render(body)11491204 }11501205 1151-func (s *exportState) renderInlineChoiceField(label, value string, focused bool, width int) string {1206+func (s *exportModel) renderInlineChoiceField(label, value string, focused bool, width int) string {11521207 border := theme.Current().Border11531208 if focused {11541209 border = theme.Current().Accent
@@ -1217,7 +1272,7 @@ func onOffLabel(enabled bool) string {
12171272 return "Off"12181273 }12191274 1220-func (s *exportState) renderSummary(width int) string {1275+func (s *exportModel) renderSummary(width int) string {12211276 total := 012221277 for _, card := range s.cards {12231278 for _, count := range card.Buckets {
@@ -1245,7 +1300,7 @@ func (s *exportState) renderSummary(width int) string {
12451300 12461301 var bucketNames = [3]string{"Easy", "Medium", "Hard"}12471302 1248-func (s *exportState) renderCards(width, height int) string {1303+func (s *exportModel) renderCards(width, height int) string {12491304 if len(s.cards) == 0 {12501305 return ui.DimItemStyle().Render("No exportable games found.")12511306 }
@@ -1278,7 +1333,7 @@ func (s *exportState) renderCards(width, height int) string {
12781333 return content12791334 }12801335 1281-func (s *exportState) renderCard(index, width int) string {1336+func (s *exportModel) renderCard(index, width int) string {12821337 card := s.cards[index]12831338 selected := index == s.cardIndex12841339
@@ -1321,7 +1376,7 @@ func (s *exportState) renderCard(index, width int) string {
13211376 Render(body)13221377 }13231378 1324-func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {1379+func (s *exportModel) renderDifficultyPair(card exportGameCard, cardSelected bool, bucketIndex int, bucketName string) string {13251380 palette := theme.Current()13261381 bg, fg := exportDifficultyColors(bucketIndex, palette)13271382 pairStyle := lipgloss.NewStyle().
@@ -1340,28 +1395,28 @@ func (s *exportState) renderDifficultyPair(card exportGameCard, cardSelected boo
13401395 return pair13411396 }13421397 1343-func (s *exportState) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, screenAction, bool) {1398+func (s *exportModel) handleMouseClick(msg tea.MouseClickMsg, screenWidth, screenHeight, contentWidth, contentHeight int) (tea.Cmd, bool) {13441399 mouse := msg.Mouse()13451400 content := s.view(contentWidth, contentHeight)13461401 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)13471402 localX := mouse.X - contentX13481403 localY := mouse.Y - contentY13491404 if localX < 0 || localY < 0 {1350- return nil, nil, false1405+ return nil, false13511406 }13521407 13531408 metrics := s.contentMetrics(contentWidth, contentHeight)13541409 if cmd, handled := s.handleSettingsClick(localX, localY, metrics); handled {1355- return cmd, nil, true1410+ return cmd, true13561411 }13571412 if metrics.SummaryButton.contains(localX, localY) {13581413 s.focus = exportFocusSubmit1359- return s.applyFocus(), exportSubmitAction{}, true1414+ return tea.Batch(s.applyFocus(), exportSubmitCmd), true13601415 }13611416 if cmd, handled := s.handleCardClick(localX, localY, metrics); handled {1362- return cmd, nil, true1417+ return cmd, true13631418 }1364- return nil, nil, false1419+ return nil, false13651420 }13661421 13671422 func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (panelX, panelY, contentX, contentY int) {
@@ -1377,7 +1432,7 @@ func exportPanelContentOrigin(screenWidth, screenHeight int, content string) (pa
13771432 return panelX, panelY, contentX, contentY13781433 }13791434 1380-func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {1435+func (s *exportModel) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, screenHeight, contentWidth, contentHeight int) bool {13811436 mouse := msg.Mouse()13821437 content := s.view(contentWidth, contentHeight)13831438 _, _, contentX, contentY := exportPanelContentOrigin(screenWidth, screenHeight, content)
@@ -1406,7 +1461,7 @@ func (s *exportState) handleMouseWheel(msg tea.MouseWheelMsg, screenWidth, scree
14061461 return true14071462 }14081463 1409-func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {1464+func (s *exportModel) clampSelectionToVisibleRows(cols, visibleRows int) {14101465 if len(s.cards) == 0 || cols <= 0 || visibleRows <= 0 {14111466 return14121467 }
@@ -1426,7 +1481,7 @@ func (s *exportState) clampSelectionToVisibleRows(cols, visibleRows int) {
14261481 s.cardIndex = max(index, 0)14271482 }14281483 1429-func (s *exportState) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1484+func (s *exportModel) handleSettingsClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {14301485 settings := metrics.Settings14311486 switch {14321487 case settings.Title.contains(localX, localY):
@@ -1456,7 +1511,7 @@ func (s *exportState) handleSettingsClick(localX, localY int, metrics exportCont
14561511 }14571512 }14581513 1459-func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {1514+func (s *exportModel) focusTextInput(focus exportFocus, input *textinput.Model, rect exportRect, label string, localX int) tea.Cmd {14601515 s.focus = focus14611516 labelWidth := lipgloss.Width(label) + 114621517 cursorPos := max(localX-rect.X-2-labelWidth, 0)
@@ -1464,7 +1519,7 @@ func (s *exportState) focusTextInput(focus exportFocus, input *textinput.Model,
14641519 return s.applyFocus()14651520 }14661521 1467-func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {1522+func (s *exportModel) focusTextarea(rect exportRect, localX, localY int) tea.Cmd {14681523 s.focus = exportFocusHeader14691524 line := max(localY-rect.Y-2, 0)14701525 col := max(localX-rect.X-2, 0)
@@ -1478,7 +1533,7 @@ func (s *exportState) focusTextarea(rect exportRect, localX, localY int) tea.Cmd
14781533 return s.applyFocus()14791534 }14801535 1481-func (s *exportState) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {1536+func (s *exportModel) handleCardClick(localX, localY int, metrics exportContentMetrics) (tea.Cmd, bool) {14821537 if len(s.cards) == 0 {14831538 return nil, false14841539 }
@@ -1496,7 +1551,7 @@ func (s *exportState) handleCardClick(localX, localY int, metrics exportContentM
14961551 return nil, false14971552 }14981553 1499-func (s *exportState) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {1554+func (s *exportModel) selectCardBucket(cardIndex, bucketIndex int, metrics exportContentMetrics) tea.Cmd {15001555 s.focus = exportFocusCards15011556 s.cardIndex = cardIndex15021557 s.bucketIndex = min(max(bucketIndex, 0), len(bucketNames)-1)
@@ -1569,7 +1624,7 @@ func truncateLabel(s string, width int) string {
15691624 return string(runes[:width-1]) + "…"15701625 }15711626 1572-func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {1627+func (s exportModel) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {15731628 sync := s15741629 sync.syncValuesFromInputs()15751630
@@ -1594,7 +1649,7 @@ func (s exportState) toSpecAndConfig() (packexport.Spec, config.ExportConfig) {
15941649 return spec, cfg15951650 }15961651 1597-func (s *exportState) syncValuesFromInputs() {1652+func (s *exportModel) syncValuesFromInputs() {15981653 s.values.Title = s.titleInput.Value()15991654 s.values.Header = s.headerInput.Value()16001655 s.values.Advert = s.advertInput.Value()
@@ -1650,3 +1705,16 @@ func expandCardCounts(cards []exportGameCard) map[puzzle.GameID]map[puzzle.ModeI
16501705 }16511706 return counts16521707 }1708+1709+// runeIdxBeforeExt returns the rune index just before the file extension so1710+// that focusing a path field via tab lands the cursor there. This lets users1711+// type a new base-name suffix (e.g. "2") without overwriting ".pdf"/".jsonl".1712+func runeIdxBeforeExt(path string) int {1713+ ext := filepath.Ext(path)1714+ runes := []rune(path)1715+ extRunes := len([]rune(ext))1716+ if extRunes == 0 || extRunes >= len(runes) {1717+ return len(runes)1718+ }1719+ return len(runes) - extRunes1720+}
M app/export_test.go +46 -45
@@ -8,7 +8,7 @@ import (
88 tea "charm.land/bubbletea/v2"99 "charm.land/lipgloss/v2"1010 "github.com/FelineStateMachine/puzzletea/config"11- "github.com/FelineStateMachine/puzzletea/packexport"11+ "github.com/FelineStateMachine/puzzletea/export/pack"1212 "github.com/FelineStateMachine/puzzletea/puzzle"1313 )1414
@@ -34,24 +34,24 @@ func TestHandleExportEnterLoadsPersistedDefaults(t *testing.T) {
3434 height: 30,3535 }3636 37- next, _ := m.handleExportEnter()38- got := next.(model)37+ got, _ := m.handleExportEnter()3938 39+ es, ok := got.screens[exportView].(exportScreen)40+ if !ok {41+ t.Fatal("expected export screen to be initialized")42+ }4043 if got.state != exportView {4144 t.Fatalf("state = %d, want %d", got.state, exportView)4245 }43- if got.export.values.Title != "Saved Sampler" {44- t.Fatalf("title = %q, want %q", got.export.values.Title, "Saved Sampler")46+ if es.export.values.Title != "Saved Sampler" {47+ t.Fatalf("title = %q, want %q", es.export.values.Title, "Saved Sampler")4548 }46- if got.export.values.Volume != "3" {47- t.Fatalf("volume = %q, want %q", got.export.values.Volume, "3")49+ if es.export.values.Volume != "3" {50+ t.Fatalf("volume = %q, want %q", es.export.values.Volume, "3")4851 }49- if got.export.cards[findExportCardIndex(got.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {52+ if es.export.cards[findExportCardIndex(es.export.cards, puzzle.CanonicalGameID("Sudoku"))].Buckets[0] != 4 {5053 t.Fatal("expected persisted count to populate the form")5154 }52- if !got.export.initialized {53- t.Fatal("expected export editor to be initialized")54- }5555 }5656 5757 func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {
@@ -62,22 +62,21 @@ func TestHandleExportCompleteSuccessShowsSuccessNotice(t *testing.T) {
6262 state: exportRunningView,6363 width: 100,6464 height: 30,65- export: exportState{66- jobID: 7,67- initialized: true,68- values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),69- cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),65+ screens: map[viewState]screenModel{66+ exportView: exportScreen{67+ jobID: 7,68+ export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},69+ },7070 },7171 }7272 73- next, _ := m.handleExportComplete(exportCompleteMsg{73+ got, _ := m.handleExportComplete(exportCompleteMsg{7474 jobID: 7,7575 result: packexport.Result{7676 TotalCount: 10,7777 PDFOutputPath: filepath.Join(tmp, "sample.pdf"),7878 },7979 })80- got := next.(model)8180 8281 if got.state != exportView {8382 t.Fatalf("state = %d, want %d", got.state, exportView)
@@ -102,15 +101,15 @@ func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {
102101 state: exportRunningView,103102 width: 100,104103 height: 30,105- export: exportState{106- jobID: 8,107- initialized: true,108- values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),109- cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),104+ screens: map[viewState]screenModel{105+ exportView: exportScreen{106+ jobID: 8,107+ export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},108+ },110109 },111110 }112111 113- next, _ := m.handleExportComplete(exportCompleteMsg{112+ got, _ := m.handleExportComplete(exportCompleteMsg{114113 jobID: 8,115114 result: packexport.Result{116115 TotalCount: 10,
@@ -118,7 +117,6 @@ func TestHandleExportCompleteSuccessIncludesJSONLWhenPresent(t *testing.T) {
118117 JSONLOutputPath: jsonlPath,119118 },120119 })121- got := next.(model)122120 123121 if !strings.Contains(got.notice.message, jsonlPath) {124122 t.Fatalf("notice message = %q, want jsonl output path", got.notice.message)
@@ -133,19 +131,18 @@ func TestHandleExportCompleteFailureShowsErrorNotice(t *testing.T) {
133131 state: exportRunningView,134132 width: 100,135133 height: 30,136- export: exportState{137- jobID: 9,138- initialized: true,139- values: exportValuesFromSpec(packexport.DefaultSpec(tmp)),140- cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp)),134+ screens: map[viewState]screenModel{135+ exportView: exportScreen{136+ jobID: 9,137+ export: exportModel{values: exportValuesFromSpec(packexport.DefaultSpec(tmp)), cards: buildExportCardsFromSpec(packexport.DefaultSpec(tmp))},138+ },141139 },142140 }143141 144- next, _ := m.handleExportComplete(exportCompleteMsg{142+ got, _ := m.handleExportComplete(exportCompleteMsg{145143 jobID: 9,146144 err: assertiveError("boom"),147145 })148- got := next.(model)149146 150147 if got.notice.level != noticeLevelError {151148 t.Fatalf("notice level = %q, want %q", got.notice.level, noticeLevelError)
@@ -165,11 +162,12 @@ func TestHandleExportSubmitRejectsInvalidVolumeInput(t *testing.T) {
165162 state: exportView,166163 width: 100,167164 height: 30,168- export: state,165+ screens: map[viewState]screenModel{166+ exportView: exportScreen{export: state, width: 100, height: 30},167+ },169168 }170169 171- next, _ := m.handleExportSubmit()172- got := next.(model)170+ got, _ := m.handleExportSubmit()173171 174172 if got.state != exportView {175173 t.Fatalf("state = %d, want %d", got.state, exportView)
@@ -311,12 +309,15 @@ func TestExportMouseClickOnSummaryButtonSubmits(t *testing.T) {
311309 X: contentX + metrics.Width - buttonWidth + 1,312310 Y: contentY + metrics.SettingsHeight,313311 }314- _, action, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)312+ cmd, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)315313 if !handled {316314 t.Fatal("expected export button click to be handled")317315 }318- if _, ok := action.(exportSubmitAction); !ok {319- t.Fatalf("action = %T, want exportSubmitAction", action)316+ if state.focus != exportFocusSubmit {317+ t.Fatalf("focus = %v, want exportFocusSubmit after submit button click", state.focus)318+ }319+ if cmd == nil {320+ t.Fatal("expected submit button click to return a non-nil command")320321 }321322 }322323
@@ -350,7 +351,7 @@ func TestExportMouseClickFocusesTextFields(t *testing.T) {
350351 X: contentX + tt.rect.X + min(tt.rect.W-2, 14),351352 Y: contentY + tt.rect.Y + 1,352353 }353- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)354+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)354355 if !handled {355356 t.Fatalf("%s click was not handled", tt.name)356357 }
@@ -381,12 +382,12 @@ func TestExportMouseClickRepositionsTextCursor(t *testing.T) {
381382 X: contentX + metrics.Settings.Title.X + 20,382383 Y: contentY + metrics.Settings.Title.Y + 1,383384 }384- _, _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)385+ _, handled := state.handleMouseClick(first, screenWidth, screenHeight, contentWidth, contentHeight)385386 if !handled {386387 t.Fatal("expected first title click to be handled")387388 }388389 firstPos := state.titleInput.Position()389- _, _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)390+ _, handled = state.handleMouseClick(second, screenWidth, screenHeight, contentWidth, contentHeight)390391 if !handled {391392 t.Fatal("expected second title click to be handled")392393 }
@@ -410,7 +411,7 @@ func TestExportMouseClickFocusesHeaderTextarea(t *testing.T) {
410411 X: contentX + metrics.Settings.Header.X + 18,411412 Y: contentY + metrics.Settings.Header.Y + 4,412413 }413- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)414+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)414415 if !handled {415416 t.Fatal("expected header click to be handled")416417 }
@@ -439,7 +440,7 @@ func TestExportMouseClickTogglesJSONLImmediately(t *testing.T) {
439440 X: contentX + metrics.Settings.JSONL.X + 4,440441 Y: contentY + metrics.Settings.JSONL.Y + 1,441442 }442- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)443+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)443444 if !handled {444445 t.Fatal("expected JSONL click to be handled")445446 }
@@ -465,7 +466,7 @@ func TestExportMouseClickCyclesLayoutImmediately(t *testing.T) {
465466 X: contentX + metrics.Settings.Layout.X + 5,466467 Y: contentY + metrics.Settings.Layout.Y + 1,467468 }468- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)469+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)469470 if !handled {470471 t.Fatal("expected layout click to be handled")471472 }
@@ -490,7 +491,7 @@ func TestExportMouseClickSelectsCardBucketWithoutChangingCount(t *testing.T) {
490491 X: contentX + target.Buckets[1].X + 1,491492 Y: contentY + target.Buckets[1].Y,492493 }493- _, _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)494+ _, handled := state.handleMouseClick(click, screenWidth, screenHeight, contentWidth, contentHeight)494495 if !handled {495496 t.Fatal("expected card bucket click to be handled")496497 }
M app/handle_daily.go +1 -1
M app/handle_game.go +0 -50
@@ -1,55 +1,5 @@
11 package app22 3-import (4- "github.com/FelineStateMachine/puzzletea/registry"5- sessionflow "github.com/FelineStateMachine/puzzletea/session"6- "github.com/FelineStateMachine/puzzletea/store"7- "github.com/FelineStateMachine/puzzletea/ui"8-9- tea "charm.land/bubbletea/v2"10-)11-12-func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {13- entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())14- if !ok {15- return m, nil16- }17- m.nav.selectedCategory = entry18- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")19- m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))20- m.state = modeSelectView21- return m, nil22-}23-24-func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {25- item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())26- mode, ok := item.(registry.ModeEntry)27- if !ok {28- return m, nil29- }30- m.nav.selectedModeTitle = mode.Definition.Title31- cmd := newSessionController(&m).startSpawn(mode.Spawner, spawnRequest{32- source: spawnSourceNormal,33- name: sessionflow.GenerateUniqueName(m.store),34- gameType: m.nav.selectedCategory.Definition.Name,35- modeTitle: m.nav.selectedModeTitle,36- run: store.NormalRunMetadata(),37- returnState: modeSelectView,38- exitState: mainMenuView,39- })40- return m, cmd41-}42-43-func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {44- idx := m.cont.table.Cursor()45- if idx < 0 || idx >= len(m.cont.games) {46- return m, nil47- }48- rec := m.cont.games[idx]49- m, _ = m.importAndActivateRecord(rec)50- return m, nil51-}52-533 func (m model) persistCompletionIfSolved() model {544 newSessionController(&m).persistCompletionIfSolved()555 return m
M app/handle_help.go +3 -4
M app/handle_seed.go +1 -1
M app/handle_stats.go +2 -1
M app/handle_theme.go +3 -2
M app/help_layout_test.go +4 -4
M app/model.go +9 -25
@@ -19,7 +19,6 @@ import (
1919 "charm.land/bubbles/v2/list"2020 "charm.land/bubbles/v2/spinner"2121 "charm.land/bubbles/v2/table"22- "charm.land/bubbles/v2/textarea"2322 "charm.land/bubbles/v2/textinput"2423 "charm.land/bubbles/v2/viewport"2524 tea "charm.land/bubbletea/v2"
@@ -146,26 +145,6 @@ type themeState struct {
146145 previous string147146 }148147 149-type exportState struct {150- initialized bool151- focus exportFocus152- values exportFormValues153- titleInput textinput.Model154- headerInput textarea.Model155- advertInput textinput.Model156- volumeInput textinput.Model157- seedInput textinput.Model158- pdfPathInput textinput.Model159- jsonlPathInput textinput.Model160- cards []exportGameCard161- cardIndex int162- bucketIndex int163- cardRowOffset int164- running bool165- jobID int64166- cancel context.CancelFunc167-}168-169148 type debugState struct {170149 enabled bool171150 renderer *glamour.TermRenderer
@@ -206,7 +185,8 @@ type seedModeOption struct {
206185 }207186 208187 type model struct {209- state viewState188+ state viewState189+ screens map[viewState]screenModel // persistent screen instances210190 211191 nav navigationState212192 cont continueState
@@ -216,7 +196,6 @@ type model struct {
216196 help helpState217197 stats statsState218198 theme themeState219- export exportState220199 debug debugState221200 notice noticeState222201
@@ -245,8 +224,9 @@ func InitialModel(s *store.Store, cfg *config.Config, configPath string) model {
245224 r := initDebugRenderer()246225 l := ui.InitCategoryList(gameCategoryItems, "Select Category")247226 mm := ui.NewMainMenu(mainMenuItems)248- return model{227+ m := model{249228 state: mainMenuView,229+ screens: make(map[viewState]screenModel),250230 debug: debugState{renderer: r},251231 nav: navigationState{gameSelectList: l, mainMenu: mm},252232 spinner: newSpinner(),
@@ -254,6 +234,7 @@ func InitialModel(s *store.Store, cfg *config.Config, configPath string) model {
254234 cfg: cfg,255235 configPath: configPath,256236 }237+ return m.initScreen(mainMenuView)257238 }258239 259240 // InitialModelWithGame creates a model that starts directly in gameView,
@@ -262,8 +243,9 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, configPath string,
262243 r := initDebugRenderer()263244 l := ui.InitCategoryList(gameCategoryItems, "Select Category")264245 mm := ui.NewMainMenu(mainMenuItems)265- return model{246+ m := model{266247 state: gameView,248+ screens: make(map[viewState]screenModel),267249 debug: debugState{renderer: r},268250 nav: navigationState{gameSelectList: l, mainMenu: mm},269251 spinner: newSpinner(),
@@ -277,6 +259,8 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, configPath string,
277259 returnState: mainMenuView,278260 },279261 }262+ // Pre-cache the main menu screen so back-navigation from game works.263+ return m.initScreen(mainMenuView)280264 }281265 282266 func (m model) Init() tea.Cmd {
M app/notice.go +0 -4
M app/screen_core.go +223 -134
@@ -1,210 +1,299 @@
11 package app22 3-import tea "charm.land/bubbletea/v2"3+import (4+ "time"45 5-type screenAction interface{ isScreenAction() }6+ "github.com/FelineStateMachine/puzzletea/registry"7+ sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/theme"10+ "github.com/FelineStateMachine/puzzletea/ui"11+12+ tea "charm.land/bubbletea/v2"13+)14+15+type screenAction interface {16+ applyToModel(m model) (model, tea.Cmd)17+}618 719 type openPlayMenuAction struct{}820 9-func (openPlayMenuAction) isScreenAction() {}21+func (a openPlayMenuAction) applyToModel(m model) (model, tea.Cmd) {22+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))23+ m.state = playMenuView24+ m = m.clearNotice()25+ return m.initScreen(playMenuView), nil26+}1027 1128 type openStatsAction struct{}1229 13-func (openStatsAction) isScreenAction() {}30+func (a openStatsAction) applyToModel(m model) (model, tea.Cmd) {31+ return m.handleStatsEnter()32+}1433 1534 type openOptionsMenuAction struct{}1635 17-func (openOptionsMenuAction) isScreenAction() {}36+func (a openOptionsMenuAction) applyToModel(m model) (model, tea.Cmd) {37+ m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)38+ m.state = optionsMenuView39+ m = m.clearNotice()40+ return m.initScreen(optionsMenuView), nil41+}1842 1943 type quitAction struct{}2044 21-func (quitAction) isScreenAction() {}45+func (a quitAction) applyToModel(m model) (model, tea.Cmd) {46+ return m, tea.Quit47+}2248 2349 type openGameSelectAction struct{}2450 25-func (openGameSelectAction) isScreenAction() {}51+func (a openGameSelectAction) applyToModel(m model) (model, tea.Cmd) {52+ m.state = gameSelectView53+ m = m.updateCategoryDetailViewport()54+ m = m.clearNotice()55+ return m.initScreen(gameSelectView), nil56+}2657 2758 type openContinueAction struct{}2859 29-func (openContinueAction) isScreenAction() {}60+func (a openContinueAction) applyToModel(m model) (model, tea.Cmd) {61+ m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)62+ m.state = continueView63+ m = m.clearNotice()64+ return m.initScreen(continueView), nil65+}3066 3167 type openDailyAction struct{}3268 33-func (openDailyAction) isScreenAction() {}69+func (a openDailyAction) applyToModel(m model) (model, tea.Cmd) {70+ return m.handleDailyPuzzle()71+}3472 35-type openWeeklyAction struct{}73+type openExportAction struct{}3674 37-func (openWeeklyAction) isScreenAction() {}75+func (a openExportAction) applyToModel(m model) (model, tea.Cmd) {76+ return m.handleExportEnter()77+}3878 39-type openSeedInputAction struct{}79+type openWeeklyAction struct{}4080 41-func (openSeedInputAction) isScreenAction() {}81+func (a openWeeklyAction) applyToModel(m model) (model, tea.Cmd) {82+ return m.enterWeeklyView()83+}4284 43-type openExportAction struct{}85+type openSeedInputAction struct{}4486 45-func (openExportAction) isScreenAction() {}87+func (a openSeedInputAction) applyToModel(m model) (model, tea.Cmd) {88+ return m.enterSeedInputView()89+}4690 4791 type backAction struct {4892 target viewState4993 }5094 51-func (backAction) isScreenAction() {}95+func (a backAction) applyToModel(m model) (model, tea.Cmd) {96+ if m.state == themeSelectView {97+ previous := m.theme.previous98+ if ts, ok := m.screens[themeSelectView].(themeSelectScreen); ok {99+ previous = ts.theme.previous100+ }101+ _ = theme.Apply(previous)102+ }103+ m.state = a.target104+ return m.resizeActiveScreen(), nil105+}52106 53-type gameSelectEnterAction struct{}107+type gameSelectEnterAction struct {108+ entry registry.Entry109+}54110 55-func (gameSelectEnterAction) isScreenAction() {}111+func (a gameSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {112+ m.nav.selectedCategory = a.entry113+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(a.entry), a.entry.Definition.Name+" - Select Mode")114+ m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))115+ m.state = modeSelectView116+ m = m.initScreen(modeSelectView)117+ return m, nil118+}56119 57-type modeSelectEnterAction struct{}120+type modeSelectEnterAction struct {121+ entry registry.Entry122+ mode registry.ModeEntry123+}58124 59-func (modeSelectEnterAction) isScreenAction() {}125+func (a modeSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {126+ m.nav.selectedCategory = a.entry127+ m.nav.selectedModeTitle = a.mode.Definition.Title128+ cmd := newSessionController(&m).startSpawn(a.mode.Spawner, spawnRequest{129+ source: spawnSourceNormal,130+ name: sessionflow.GenerateUniqueName(m.store),131+ gameType: a.entry.Definition.Name,132+ modeTitle: a.mode.Definition.Title,133+ run: store.NormalRunMetadata(),134+ returnState: modeSelectView,135+ exitState: mainMenuView,136+ })137+ return m, cmd138+}60139 61-type continueEnterAction struct{}140+type continueEnterAction struct {141+ record store.GameRecord142+}62143 63-func (continueEnterAction) isScreenAction() {}144+func (a continueEnterAction) applyToModel(m model) (model, tea.Cmd) {145+ m, _ = m.importAndActivateRecord(a.record)146+ return m, nil147+}64148 65149 type weeklyShiftAction struct {66- delta int150+ delta int151+ weekly weeklyState67152 }68153 69-func (weeklyShiftAction) isScreenAction() {}154+func (a weeklyShiftAction) applyToModel(m model) (model, tea.Cmd) {155+ m.weekly = a.weekly156+ m = m.moveWeeklyWeek(a.delta)157+ m = m.initScreen(weeklyView)158+ return m, nil159+}70160 71-type weeklyEnterAction struct{}161+type weeklyEnterAction struct {162+ weekly weeklyState163+}72164 73-func (weeklyEnterAction) isScreenAction() {}165+func (a weeklyEnterAction) applyToModel(m model) (model, tea.Cmd) {166+ m.weekly = a.weekly167+ return m.handleWeeklyEnter()168+}74169 75-type helpSelectEnterAction struct{}170+type helpSelectEnterAction struct {171+ entry registry.Entry172+}76173 77-func (helpSelectEnterAction) isScreenAction() {}174+func (a helpSelectEnterAction) applyToModel(m model) (model, tea.Cmd) {175+ m.help.category = a.entry176+ return m.handleHelpSelectEnter()177+}78178 79179 type openThemeSelectAction struct{}80180 81-func (openThemeSelectAction) isScreenAction() {}181+func (a openThemeSelectAction) applyToModel(m model) (model, tea.Cmd) {182+ return m.handleThemeEnter()183+}82184 83185 type openHelpSelectAction struct{}84186 85-func (openHelpSelectAction) isScreenAction() {}187+func (a openHelpSelectAction) applyToModel(m model) (model, tea.Cmd) {188+ m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")189+ m.state = helpSelectView190+ m = m.clearNotice()191+ return m.initScreen(helpSelectView), nil192+}86193 87194 type previewThemeAction struct {88195 name string89196 }90197 91-func (previewThemeAction) isScreenAction() {}198+func (a previewThemeAction) applyToModel(m model) (model, tea.Cmd) {199+ _ = theme.Apply(a.name)200+ if ts, ok := m.screens[themeSelectView].(themeSelectScreen); ok {201+ ui.UpdateThemeListStyles(&ts.theme.list)202+ m.screens[themeSelectView] = ts203+ }204+ return m, nil205+}92206 93-type confirmThemeAction struct{}207+type confirmThemeAction struct {208+ theme themeState209+}94210 95-func (confirmThemeAction) isScreenAction() {}211+func (a confirmThemeAction) applyToModel(m model) (model, tea.Cmd) {212+ m.theme = a.theme213+ return m.handleThemeConfirm()214+}96215 97-type seedConfirmAction struct{}216+type seedConfirmAction struct {217+ seed seedState218+}98219 99-func (seedConfirmAction) isScreenAction() {}220+func (a seedConfirmAction) applyToModel(m model) (model, tea.Cmd) {221+ m.seed = a.seed222+ return m.handleSeedConfirm()223+}100224 225+// exportSubmitAction is a pure tea.Msg emitted by exportSubmitCmd.226+// It is handled in the root Update() and does not implement screenAction.101227 type exportSubmitAction struct{}102228 103-func (exportSubmitAction) isScreenAction() {}104-105229 type screenModel interface {106- State() viewState107230 Resize(width, height int) screenModel108231 Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)109232 View(notice noticeState) string110- Apply(m model) model233+}234+235+type screenFactory func(m model) screenModel236+237+var screenRegistry = map[viewState]screenFactory{238+ mainMenuView: func(m model) screenModel {239+ return mainMenuScreen{width: m.width, height: m.height, menu: m.nav.mainMenu}240+ },241+ playMenuView: func(m model) screenModel {242+ return playMenuScreen{width: m.width, height: m.height, menu: m.nav.playMenu}243+ },244+ optionsMenuView: func(m model) screenModel {245+ return optionsMenuScreen{width: m.width, height: m.height, menu: m.nav.optionsMenu}246+ },247+ seedInputView: func(m model) screenModel {248+ return seedInputScreen{width: m.width, height: m.height, seed: m.seed}249+ },250+ gameSelectView: func(m model) screenModel {251+ return gameSelectScreen{width: m.width, height: m.height, list: m.nav.gameSelectList, detail: m.nav.categoryDetail}252+ },253+ modeSelectView: func(m model) screenModel {254+ return modeSelectScreen{width: m.width, height: m.height, entry: m.nav.selectedCategory, list: m.nav.modeSelectList}255+ },256+ continueView: func(m model) screenModel {257+ return continueScreen{width: m.width, height: m.height, cont: m.cont}258+ },259+ weeklyView: func(m model) screenModel {260+ return weeklyScreen{width: m.width, height: m.height, weekly: m.weekly}261+ },262+ helpSelectView: func(m model) screenModel {263+ return helpSelectScreen{width: m.width, height: m.height, help: m.help}264+ },265+ helpDetailView: func(m model) screenModel {266+ return helpDetailScreen{width: m.width, height: m.height, help: m.help}267+ },268+ statsView: func(m model) screenModel {269+ return statsScreen{width: m.width, height: m.height, stats: m.stats}270+ },271+ themeSelectView: func(m model) screenModel {272+ return themeSelectScreen{width: m.width, height: m.height, theme: m.theme}273+ },274+ generatingView: func(m model) screenModel {275+ return generatingScreen{width: m.width, height: m.height, spinner: m.spinner}276+ },277+ exportRunningView: func(m model) screenModel {278+ return exportRunningScreen{width: m.width, height: m.height, spinner: m.spinner}279+ },111280 }112281 113282 func (m model) activeScreen() screenModel {114- switch m.state {115- case mainMenuView:116- return mainMenuScreen{117- width: m.width,118- height: m.height,119- menu: m.nav.mainMenu,120- }121- case playMenuView:122- return playMenuScreen{123- width: m.width,124- height: m.height,125- menu: m.nav.playMenu,126- }127- case optionsMenuView:128- return optionsMenuScreen{129- width: m.width,130- height: m.height,131- menu: m.nav.optionsMenu,132- }133- case seedInputView:134- return seedInputScreen{135- width: m.width,136- height: m.height,137- seed: m.seed,138- }139- case gameSelectView:140- return gameSelectScreen{141- width: m.width,142- height: m.height,143- list: m.nav.gameSelectList,144- detail: m.nav.categoryDetail,145- }146- case modeSelectView:147- return modeSelectScreen{148- width: m.width,149- height: m.height,150- entry: m.nav.selectedCategory,151- list: m.nav.modeSelectList,152- }153- case exportView:154- return exportScreen{155- width: m.width,156- height: m.height,157- export: m.export,158- }159- case continueView:160- return continueScreen{161- width: m.width,162- height: m.height,163- cont: m.cont,164- }165- case weeklyView:166- return weeklyScreen{167- width: m.width,168- height: m.height,169- weekly: m.weekly,170- }171- case helpSelectView:172- return helpSelectScreen{173- width: m.width,174- height: m.height,175- help: m.help,176- }177- case helpDetailView:178- return helpDetailScreen{179- width: m.width,180- height: m.height,181- help: m.help,182- }183- case statsView:184- return statsScreen{185- width: m.width,186- height: m.height,187- stats: m.stats,188- }189- case themeSelectView:190- return themeSelectScreen{191- width: m.width,192- height: m.height,193- theme: m.theme,194- }195- case generatingView:196- return generatingScreen{197- width: m.width,198- height: m.height,199- spinner: m.spinner,200- }201- case exportRunningView:202- return exportRunningScreen{203- width: m.width,204- height: m.height,205- spinner: m.spinner,206- }207- default:208- return nil283+ return m.screens[m.state]284+}285+286+// initScreen creates a fresh screen for the given state (using the registry287+// factory) and stores it in m.screens. It also calls Resize so dimensions288+// are set correctly.289+func (m model) initScreen(state viewState) model {290+ factory, ok := screenRegistry[state]291+ if !ok {292+ return m293+ }294+ if m.screens == nil {295+ m.screens = make(map[viewState]screenModel)209296 }297+ m.screens[state] = factory(m).Resize(m.width, m.height)298+ return m210299 }
M app/screen_menu.go +0 -24
@@ -13,8 +13,6 @@ type mainMenuScreen struct {
1313 menu ui.MainMenu1414 }1515 16-func (s mainMenuScreen) State() viewState { return mainMenuView }17-1816 func (s mainMenuScreen) Resize(width, height int) screenModel {1917 s.width = width2018 s.height = height
@@ -48,20 +46,12 @@ func (s mainMenuScreen) View(notice noticeState) string {
4846 return centerContentWithNotice(s.width, s.height, notice, s.menu.View())4947 }5048 51-func (s mainMenuScreen) Apply(m model) model {52- m.state = mainMenuView53- m.nav.mainMenu = s.menu54- return m55-}56-5749 type playMenuScreen struct {5850 width int5951 height int6052 menu ui.MainMenu6153 }6254 63-func (s playMenuScreen) State() viewState { return playMenuView }64-6555 func (s playMenuScreen) Resize(width, height int) screenModel {6656 s.width = width6757 s.height = height
@@ -100,20 +90,12 @@ func (s playMenuScreen) View(notice noticeState) string {
10090 return centerContentWithNotice(s.width, s.height, notice, s.menu.ViewAsPanel("Play"))10191 }10292 103-func (s playMenuScreen) Apply(m model) model {104- m.state = playMenuView105- m.nav.playMenu = s.menu106- return m107-}108-10993 type optionsMenuScreen struct {11094 width int11195 height int11296 menu ui.MainMenu11397 }11498 115-func (s optionsMenuScreen) State() viewState { return optionsMenuView }116-11799 func (s optionsMenuScreen) Resize(width, height int) screenModel {118100 s.width = width119101 s.height = height
@@ -146,9 +128,3 @@ func (s optionsMenuScreen) View(notice noticeState) string {
146128 items := s.menu.RenderItems() + "\n\n" + ui.DimItemStyle().Render("- Dami")147129 return renderPanelView(s.width, s.height, notice, "Options", items, "↑/↓ navigate • enter select • esc back")148130 }149-150-func (s optionsMenuScreen) Apply(m model) model {151- m.state = optionsMenuView152- m.nav.optionsMenu = s.menu153- return m154-}
M app/screen_navigation.go +28 -93
@@ -17,8 +17,6 @@ type seedInputScreen struct {
1717 seed seedState1818 }1919 20-func (s seedInputScreen) State() viewState { return seedInputView }21-2220 func (s seedInputScreen) Resize(width, height int) screenModel {2321 s.width = width2422 s.height = height
@@ -32,7 +30,7 @@ func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction
3230 switch {3331 case key.Matches(keyMsg, rootKeys.Enter):3432 if sessionflow.NormalizeSeed(s.seed.input.Value()) != "" {35- return s, nil, seedConfirmAction{}33+ return s, nil, seedConfirmAction{seed: s.seed}3634 }3735 return s, nil, nil3836 case key.Matches(keyMsg, rootKeys.Escape):
@@ -47,25 +45,13 @@ func (s seedInputScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction
4745 }4846 4947 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(48+ return renderPanelView(s.width, s.height, notice,5749 "Enter Seed",58- m.seedInputBody(),50+ seedInputBody(s.seed),5951 "↑/↓ change field • ←/→ game • enter confirm • esc back",6052 )6153 }6254 63-func (s seedInputScreen) Apply(m model) model {64- m.state = seedInputView65- m.seed = s.seed66- return m67-}68-6955 type gameSelectScreen struct {7056 width int7157 height int
@@ -73,8 +59,6 @@ type gameSelectScreen struct {
7359 detail viewport.Model7460 }7561 76-func (s gameSelectScreen) State() viewState { return gameSelectView }77-7862 func (s gameSelectScreen) Resize(width, height int) screenModel {7963 m := model{8064 width: width,
@@ -105,7 +89,11 @@ func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
10589 case s.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):10690 case s.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):10791 case key.Matches(keyMsg, rootKeys.Enter):108- return s, nil, gameSelectEnterAction{}92+ entry, ok := selectedCategoryEntry(s.list.SelectedItem())93+ if !ok {94+ return s, nil, nil95+ }96+ return s, nil, gameSelectEnterAction{entry: entry}10997 case key.Matches(keyMsg, rootKeys.Escape):11098 return s, nil, backAction{target: playMenuView}11199 case keyMsg.String() == "pgup":
@@ -127,23 +115,7 @@ func (s gameSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
127115 }128116 129117 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 m118+ return gameSelectViewContent(s.width, s.height, s.list, s.detail, notice)147119 }148120 149121 func (s gameSelectScreen) resizeSelf() gameSelectScreen {
@@ -157,8 +129,6 @@ type modeSelectScreen struct {
157129 list list.Model158130 }159131 160-func (s modeSelectScreen) State() viewState { return modeSelectView }161-162132 func (s modeSelectScreen) Resize(width, height int) screenModel {163133 s.width = width164134 s.height = height
@@ -171,7 +141,12 @@ func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
171141 if ok {172142 switch {173143 case key.Matches(keyMsg, rootKeys.Enter):174- return s, nil, modeSelectEnterAction{}144+ item := unwrapModeDisplayItem(s.list.SelectedItem())145+ mode, ok := item.(registry.ModeEntry)146+ if !ok {147+ return s, nil, nil148+ }149+ return s, nil, modeSelectEnterAction{entry: s.entry, mode: mode}175150 case key.Matches(keyMsg, rootKeys.Escape):176151 return s, nil, backAction{target: gameSelectView}177152 }
@@ -183,37 +158,19 @@ func (s modeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
183158 }184159 185160 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(),161+ return renderPanelView(s.width, s.height, notice,162+ s.entry.Definition.Name+" — Select Mode",163+ s.list.View(),198164 "↑/↓ navigate • enter select • esc back",199165 )200166 }201167 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-209168 type continueScreen struct {210169 width int211170 height int212171 cont continueState213172 }214173 215-func (s continueScreen) State() viewState { return continueView }216-217174 func (s continueScreen) Resize(width, height int) screenModel {218175 s.width = width219176 s.height = height
@@ -228,7 +185,11 @@ func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)
228185 if ok {229186 switch {230187 case key.Matches(keyMsg, rootKeys.Enter):231- return s, nil, continueEnterAction{}188+ idx := s.cont.table.Cursor()189+ if idx >= 0 && idx < len(s.cont.games) {190+ return s, nil, continueEnterAction{record: s.cont.games[idx]}191+ }192+ return s, nil, nil232193 case key.Matches(keyMsg, rootKeys.Escape):233194 return s, nil, backAction{target: playMenuView}234195 }
@@ -240,19 +201,7 @@ func (s continueScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction)
240201 }241202 242203 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 m204+ return renderContinueView(s.cont, s.width, s.height, notice)256205 }257206 258207 type weeklyScreen struct {
@@ -261,8 +210,6 @@ type weeklyScreen struct {
261210 weekly weeklyState262211 }263212 264-func (s weeklyScreen) State() viewState { return weeklyView }265-266213 func (s weeklyScreen) Resize(width, height int) screenModel {267214 s.width = width268215 s.height = height
@@ -279,11 +226,11 @@ func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
279226 case key.Matches(keyMsg, rootKeys.Escape):280227 return s, nil, backAction{target: playMenuView}281228 case key.Matches(keyMsg, rootKeys.Enter):282- return s, nil, weeklyEnterAction{}229+ return s, nil, weeklyEnterAction{weekly: s.weekly}283230 case keyMsg.String() == "left" || keyMsg.String() == "h":284- return s, nil, weeklyShiftAction{delta: -1}231+ return s, nil, weeklyShiftAction{delta: -1, weekly: s.weekly}285232 case keyMsg.String() == "right" || keyMsg.String() == "l":286- return s, nil, weeklyShiftAction{delta: 1}233+ return s, nil, weeklyShiftAction{delta: 1, weekly: s.weekly}287234 }288235 }289236
@@ -293,17 +240,5 @@ func (s weeklyScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
293240 }294241 295242 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 m243+ return centerContentWithNotice(s.width, s.height, notice, weeklyViewContent(s.weekly))309244 }
M app/screen_support.go +10 -73
@@ -16,8 +16,6 @@ type helpSelectScreen struct {
1616 help helpState1717 }1818 19-func (s helpSelectScreen) State() viewState { return helpSelectView }20-2119 func (s helpSelectScreen) Resize(width, height int) screenModel {2220 s.width = width2321 s.height = height
@@ -31,7 +29,8 @@ func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
3129 if ok {3230 switch {3331 case key.Matches(keyMsg, rootKeys.Enter):34- return s, nil, helpSelectEnterAction{}32+ entry, _ := selectedCategoryEntry(s.help.selectList.SelectedItem())33+ return s, nil, helpSelectEnterAction{entry: entry}3534 case key.Matches(keyMsg, rootKeys.Escape):3635 return s, nil, backAction{target: optionsMenuView}3736 }
@@ -43,33 +42,19 @@ func (s helpSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
4342 }4443 4544 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(45+ return renderPanelView(s.width, s.height, notice,5346 "How to Play",54- m.help.selectList.View(),47+ s.help.selectList.View(),5548 "↑/↓ navigate • enter select • esc back",5649 )5750 }5851 59-func (s helpSelectScreen) Apply(m model) model {60- m.state = helpSelectView61- m.help.selectList = s.help.selectList62- return m63-}64-6552 type helpDetailScreen struct {6653 width int6754 height int6855 help helpState6956 }7057 71-func (s helpDetailScreen) State() viewState { return helpDetailView }72-7358 func (s helpDetailScreen) Resize(width, height int) screenModel {7459 m := model{7560 width: width,
@@ -95,33 +80,19 @@ func (s helpDetailScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActio
9580 }9681 9782 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(),83+ return renderPanelView(s.width, s.height, notice,84+ s.help.category.Definition.Name+" — Guide",85+ s.help.viewport.View(),10786 "↑/↓ scroll • esc back",10887 )10988 }11089 111-func (s helpDetailScreen) Apply(m model) model {112- m.state = helpDetailView113- m.help = s.help114- return m115-}116-11790 type statsScreen struct {11891 width int11992 height int12093 stats statsState12194 }12295 123-func (s statsScreen) State() viewState { return statsView }124-12596 func (s statsScreen) Resize(width, height int) screenModel {12697 m := model{12798 width: width,
@@ -147,19 +118,7 @@ func (s statsScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenAction) {
147118 }148119 149120 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 m121+ return renderStatsView(s.stats, s.width, s.height, notice)163122 }164123 165124 type themeSelectScreen struct {
@@ -168,8 +127,6 @@ type themeSelectScreen struct {
168127 theme themeState169128 }170129 171-func (s themeSelectScreen) State() viewState { return themeSelectView }172-173130 func (s themeSelectScreen) Resize(width, height int) screenModel {174131 const maxVisibleItems = 8175132 menuW := min(width, 64)
@@ -190,7 +147,7 @@ func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActi
190147 case s.theme.list.SettingFilter() && key.Matches(keyMsg, rootKeys.Enter):191148 case s.theme.list.FilterState() != list.Unfiltered && key.Matches(keyMsg, rootKeys.Escape):192149 case key.Matches(keyMsg, rootKeys.Enter):193- return s, nil, confirmThemeAction{}150+ return s, nil, confirmThemeAction{theme: s.theme}194151 case key.Matches(keyMsg, rootKeys.Escape):195152 return s, nil, backAction{target: optionsMenuView}196153 }
@@ -208,19 +165,7 @@ func (s themeSelectScreen) Update(msg tea.Msg) (screenModel, tea.Cmd, screenActi
208165 }209166 210167 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 m168+ return themeSelectViewContent(s.width, s.height, s.theme, notice)224169 }225170 226171 type generatingScreen struct {
@@ -229,8 +174,6 @@ type generatingScreen struct {
229174 spinner spinner.Model230175 }231176 232-func (s generatingScreen) State() viewState { return generatingView }233-234177 func (s generatingScreen) Resize(width, height int) screenModel {235178 s.width = width236179 s.height = height
@@ -248,9 +191,3 @@ func (s generatingScreen) View(notice noticeState) string {
248191 box := ui.GeneratingFrame().Render(appendNoticeContent(s.width, notice, content))249192 return ui.CenterView(s.width, s.height, box)250193 }251-252-func (s generatingScreen) Apply(m model) model {253- m.state = generatingView254- m.spinner = s.spinner255- return m256-}
M app/seed_input.go +16 -11
@@ -82,17 +82,22 @@ func (m model) enterSeedInputView() (model, tea.Cmd) {
8282 m.seed.modeIndex = index8383 m.seed.focus = seedFocusText8484 m.state = seedInputView85+ m = m.initScreen(seedInputView)8586 return m, m.seed.input.Focus()8687 }8788 8889 func (m model) currentSeedMode() seedModeOption {89- if len(m.seed.modeOptions) == 0 {90+ return currentSeedMode(m.seed)91+}92+93+func currentSeedMode(seed seedState) seedModeOption {94+ if len(seed.modeOptions) == 0 {9095 return seedModeOption{label: randomSeedModeLabel}9196 }92- if m.seed.modeIndex < 0 || m.seed.modeIndex >= len(m.seed.modeOptions) {93- return m.seed.modeOptions[0]97+ if seed.modeIndex < 0 || seed.modeIndex >= len(seed.modeOptions) {98+ return seed.modeOptions[0]9499 }95- return m.seed.modeOptions[m.seed.modeIndex]100+ return seed.modeOptions[seed.modeIndex]96101 }97102 98103 func (m model) moveSeedMode(step int) model {
@@ -146,21 +151,21 @@ func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {
146151 return m, cmd147152 }148153 149-func (m model) seedInputBody() string {150- selector := renderSeedModeTitle(m.currentSeedMode().label, m.seed.modeIndex)154+func seedInputBody(seed seedState) string {155+ selector := renderSeedModeTitle(currentSeedMode(seed).label, seed.modeIndex)151156 modePrefix := " "152- if m.seed.focus == seedFocusMode {157+ if seed.focus == seedFocusMode {153158 modePrefix = ui.CursorStyle().Render("> ")154159 }155160 156- seedInputView := m.seed.input.View()157- if m.seed.focus != seedFocusText {158- seedInputView = strings.Replace(seedInputView, ">", " ", 1)161+ inputView := seed.input.View()162+ if seed.focus != seedFocusText {163+ inputView = strings.Replace(inputView, ">", " ", 1)159164 }160165 161166 return lipgloss.JoinVertical(162167 lipgloss.Left,163- seedInputView,168+ inputView,164169 "",165170 modePrefix+selector,166171 )
M app/session_controller.go +2 -0
M app/update.go +24 -86
@@ -1,12 +1,8 @@
11 package app22 33 import (4- "time"5-64 "github.com/FelineStateMachine/puzzletea/game"75 "github.com/FelineStateMachine/puzzletea/store"8- "github.com/FelineStateMachine/puzzletea/theme"9- "github.com/FelineStateMachine/puzzletea/ui"106 117 "charm.land/bubbles/v2/key"128 tea "charm.land/bubbletea/v2"
@@ -21,7 +17,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2117 next, cmd := m.handleSpawnComplete(m.session.spawnJobID, msg)2218 return next, cmd2319 case exportCompleteMsg:24- return m.handleExportComplete(msg)20+ next, cmd := m.handleExportComplete(msg)21+ return next, cmd22+ case exportSubmitAction:23+ next, cmd := m.handleExportSubmit()24+ return next, cmd25+ case backAction:26+ return msg.applyToModel(m)2527 case tea.WindowSizeMsg:2628 m = m.handleWindowSize(msg)2729 if m.state != gameView {
@@ -45,8 +47,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
4547 return m, nil4648 }4749 50+ // Clear stale notices when the user interacts with the export form.51+ if m.state == exportView {52+ switch msg.(type) {53+ case tea.KeyPressMsg, tea.MouseClickMsg:54+ m = m.clearNotice()55+ }56+ }57+4858 nextScreen, screenCmd, action := screen.Update(msg)49- m = nextScreen.Apply(m)59+ if m.screens == nil {60+ m.screens = make(map[viewState]screenModel)61+ }62+ m.screens[m.state] = nextScreen5063 if action == nil {5164 return m, screenCmd5265 }
@@ -56,11 +69,12 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5669 }5770 5871 func (m model) resizeActiveScreen() model {59- screen := m.activeScreen()72+ screen := m.screens[m.state] // nil map read is safe; returns nil6073 if screen == nil {6174 return m6275 }63- return screen.Resize(m.width, m.height).Apply(m)76+ m.screens[m.state] = screen.Resize(m.width, m.height)77+ return m6478 }6579 6680 func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
@@ -116,6 +130,7 @@ func (m model) handleGlobalKey(msg tea.Msg) (model, tea.Cmd, bool) {
116130 m.state = returnState117131 if returnState == weeklyView {118132 m = m.refreshWeeklyBrowser()133+ m = m.initScreen(weeklyView)119134 }120135 m.debug.enabled = false121136 return m, nil, true
@@ -162,82 +177,5 @@ func (m model) activeSpawnReturnState() viewState {
162177 }163178 164179 func (m model) handleScreenAction(action screenAction) (model, tea.Cmd) {165- switch action := action.(type) {166- case openPlayMenuAction:167- m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))168- m.state = playMenuView169- m = m.clearNotice()170- return m.resizeActiveScreen(), nil171- case openStatsAction:172- return asModel(m.handleStatsEnter())173- case openOptionsMenuAction:174- m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)175- m.state = optionsMenuView176- m = m.clearNotice()177- return m.resizeActiveScreen(), nil178- case quitAction:179- return m, tea.Quit180- case openGameSelectAction:181- m.state = gameSelectView182- m = m.updateCategoryDetailViewport()183- m = m.clearNotice()184- return m.resizeActiveScreen(), nil185- case openContinueAction:186- m.cont.table, m.cont.games = ui.InitContinueTable(m.store, m.height)187- m.state = continueView188- m = m.clearNotice()189- return m.resizeActiveScreen(), nil190- case openDailyAction:191- return asModel(m.handleDailyPuzzle())192- case openExportAction:193- return asModel(m.handleExportEnter())194- case openWeeklyAction:195- return asModel(m.enterWeeklyView())196- case openSeedInputAction:197- return m.enterSeedInputView()198- case backAction:199- if m.state == themeSelectView {200- _ = theme.Apply(m.theme.previous)201- }202- m.state = action.target203- return m.resizeActiveScreen(), nil204- case gameSelectEnterAction:205- return asModel(m.handleGameSelectEnter())206- case modeSelectEnterAction:207- return asModel(m.handleModeSelectEnter())208- case continueEnterAction:209- return asModel(m.handleContinueEnter())210- case weeklyShiftAction:211- m = m.moveWeeklyWeek(action.delta)212- return m, nil213- case weeklyEnterAction:214- return asModel(m.handleWeeklyEnter())215- case helpSelectEnterAction:216- return asModel(m.handleHelpSelectEnter())217- case openThemeSelectAction:218- return asModel(m.handleThemeEnter())219- case openHelpSelectAction:220- m.help.selectList = ui.InitList(gameCategoryItems, "How to Play")221- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.help.selectList)222- m.help.selectList.SetSize(listWidth, listHeight)223- m.state = helpSelectView224- m = m.clearNotice()225- return m.resizeActiveScreen(), nil226- case previewThemeAction:227- _ = theme.Apply(action.name)228- ui.UpdateThemeListStyles(&m.theme.list)229- return m, nil230- case confirmThemeAction:231- return asModel(m.handleThemeConfirm())232- case seedConfirmAction:233- return asModel(m.handleSeedConfirm())234- case exportSubmitAction:235- return asModel(m.handleExportSubmit())236- default:237- return m, nil238- }239-}240-241-func asModel(next tea.Model, cmd tea.Cmd) (model, tea.Cmd) {242- return next.(model), cmd180+ return action.applyToModel(m)243181 }
M app/update_test.go +21 -14
@@ -11,7 +11,7 @@ import (
1111 tea "charm.land/bubbletea/v2"1212 "github.com/FelineStateMachine/puzzletea/daily"1313 "github.com/FelineStateMachine/puzzletea/game"14- "github.com/FelineStateMachine/puzzletea/lightsout"14+ "github.com/FelineStateMachine/puzzletea/games/lightsout"1515 "github.com/FelineStateMachine/puzzletea/registry"1616 sessionflow "github.com/FelineStateMachine/puzzletea/session"1717 "github.com/FelineStateMachine/puzzletea/store"
@@ -208,8 +208,7 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
208208 seed: seedState{input: ti},209209 }210210 211- next, _ := m.handleSeedConfirm()212- got := next.(model)211+ got, _ := m.handleSeedConfirm()213212 if got.state != seedInputView {214213 t.Fatalf("state = %d, want %d (seedInputView)", got.state, seedInputView)215214 }
@@ -239,20 +238,25 @@ func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {
239238 focus: seedFocusMode,240239 },241240 }241+ m = m.initScreen(seedInputView)242242 243243 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})244244 got := next.(model)245245 246- if got.seed.modeIndex != 1 {247- t.Fatalf("seedModeIndex = %d, want 1", got.seed.modeIndex)246+ si, ok := got.screens[seedInputView].(seedInputScreen)247+ if !ok {248+ t.Fatal("expected seedInputView screen to be cached")248249 }249- if got.seed.lastModeKey != options[1].key {250- t.Fatalf("lastSeedModeKey = %q, want %q", got.seed.lastModeKey, options[1].key)250+ if si.seed.modeIndex != 1 {251+ t.Fatalf("seedModeIndex = %d, want 1", si.seed.modeIndex)252+ }253+ if si.seed.lastModeKey != options[1].key {254+ t.Fatalf("lastSeedModeKey = %q, want %q", si.seed.lastModeKey, options[1].key)251255 }252256 253257 reopenedModel, _ := (model{254258 state: playMenuView,255- seed: seedState{lastModeKey: got.seed.lastModeKey},259+ seed: seedState{lastModeKey: si.seed.lastModeKey},256260 }).enterSeedInputView()257261 258262 if reopenedModel.state != seedInputView {
@@ -283,8 +287,7 @@ func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {
283287 },284288 }285289 286- next, _ := m.handleSeedConfirm()287- got := next.(model)290+ got, _ := m.handleSeedConfirm()288291 289292 if got.state != generatingView {290293 t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)
@@ -340,8 +343,7 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {
340343 store: s,341344 }342345 343- next, _ := m.handleDailyPuzzle()344- got := next.(model)346+ got, _ := m.handleDailyPuzzle()345347 if got.state != playMenuView {346348 t.Fatalf("state = %d, want %d (playMenuView)", got.state, playMenuView)347349 }
@@ -371,6 +373,7 @@ func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {
371373 gameSelectList: l,372374 },373375 }376+ m = m.initScreen(gameSelectView)374377 375378 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})376379 got := next.(model)
@@ -378,8 +381,12 @@ func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {
378381 if got.state != gameSelectView {379382 t.Fatalf("state = %d, want %d (gameSelectView)", got.state, gameSelectView)380383 }381- if got.nav.gameSelectList.FilterState() != list.Unfiltered {382- t.Fatalf("filter state = %s, want %s", got.nav.gameSelectList.FilterState(), list.Unfiltered)384+ gs, ok := got.screens[gameSelectView].(gameSelectScreen)385+ if !ok {386+ t.Fatal("expected gameSelectView screen to be cached")387+ }388+ if gs.list.FilterState() != list.Unfiltered {389+ t.Fatalf("filter state = %s, want %s", gs.list.FilterState(), list.Unfiltered)383390 }384391 }385392
M app/view.go +22 -20
@@ -8,6 +8,8 @@ import (
88 "github.com/FelineStateMachine/puzzletea/theme"99 "github.com/FelineStateMachine/puzzletea/ui"1010 11+ "charm.land/bubbles/v2/list"12+ "charm.land/bubbles/v2/viewport"1113 tea "charm.land/bubbletea/v2"1214 "charm.land/lipgloss/v2"1315 )
@@ -34,21 +36,21 @@ func (m model) viewContent() string {
3436 return screen.View(m.notice)3537 }3638 37-func (m model) gameSelectViewContent() string {38- metrics := categoryPickerSize(m.width, m.height)39+func gameSelectViewContent(width, height int, gameList list.Model, detail viewport.Model, notice noticeState) string {40+ metrics := categoryPickerSize(width, height)3941 4042 listView := lipgloss.NewStyle().4143 Width(metrics.listWidth).4244 Height(metrics.listHeight).4345 Border(lipgloss.NormalBorder(), false, true, false, false).4446 BorderForeground(theme.Current().Border).45- Render(m.nav.gameSelectList.View())47+ Render(gameList.View())4648 4749 detailBox := lipgloss.NewStyle().4850 Width(metrics.detailWidth).4951 Height(metrics.detailHeight).5052 Padding(2, 3).51- Render(m.nav.categoryDetail.View())53+ Render(detail.View())5254 5355 body := lipgloss.JoinHorizontal(lipgloss.Top, listView, strings.Repeat(" ", categoryGapWidth), detailBox)5456 if metrics.stacked {
@@ -61,17 +63,17 @@ func (m model) gameSelectViewContent() string {
6163 6264 panel := ui.Panel(6365 "Select Category",64- m.appendNotice(body),66+ appendNoticeContent(width, notice, body),6567 "↑/↓ navigate • / filter • enter select • esc back",6668 )67- return ui.CenterView(m.width, m.height, panel)69+ return ui.CenterView(width, height, panel)6870 }6971 70-func (m model) weeklyViewContent() string {71- title := "Weekly Gauntlet — " + m.weeklyPanelTitle()72- if len(m.weekly.rows) == 0 {72+func weeklyViewContent(w weeklyState) string {73+ title := "Weekly Gauntlet — " + weeklyPanelTitle(w.cursor)74+ if len(w.rows) == 0 {7375 body := "No completed puzzles for this week yet."74- if m.isCurrentWeeklySelection() {76+ if isCurrentWeeklySelection(w.cursor) {7577 body = "No weekly puzzles are available."7678 }7779 return ui.Panel(
@@ -82,15 +84,15 @@ func (m model) weeklyViewContent() string {
8284 }8385 8486 footer := "←/→ week • enter open • esc back"85- if !m.isCurrentWeeklySelection() {87+ if !isCurrentWeeklySelection(w.cursor) {8688 footer = "←/→ week • enter review • esc back"8789 }88- if pg := ui.TablePagination(m.weekly.table); pg != "" {90+ if pg := ui.TablePagination(w.table); pg != "" {8991 footer = pg + " " + footer9092 }9193 92- description := m.weekly.table.View()93- if !m.isCurrentWeeklySelection() {94+ description := w.table.View()95+ if !isCurrentWeeklySelection(w.cursor) {9496 description = lipgloss.JoinVertical(9597 lipgloss.Left,9698 ui.DimItemStyle().Render("Review only: completed puzzles from this week."),
@@ -179,19 +181,19 @@ func renderModeList(entry registry.Entry, width int) string {
179181 return strings.Join(lines, "\n")180182 }181183 182-func (m model) themeSelectViewContent() string {184+func themeSelectViewContent(width, height int, thm themeState, notice noticeState) string {183185 p := theme.Current()184186 185187 themeName := theme.DefaultThemeName186- if item, ok := m.theme.list.SelectedItem().(ui.MenuItem); ok {188+ if item, ok := thm.list.SelectedItem().(ui.MenuItem); ok {187189 themeName = item.ItemTitle188190 }189191 190192 const panelChrome = 8191- innerH := m.height - panelChrome193+ innerH := height - panelChrome192194 innerH = max(innerH, 10)193195 194- listView := m.theme.list.View()196+ listView := thm.list.View()195197 196198 previewBorder := lipgloss.NewStyle().197199 Border(lipgloss.RoundedBorder()).
@@ -205,8 +207,8 @@ func (m model) themeSelectViewContent() string {
205207 206208 panel := ui.Panel(207209 "Select Theme",208- m.appendNotice(body),210+ appendNoticeContent(width, notice, body),209211 "↑/↓ navigate • / filter • enter select • esc back",210212 )211- return ui.CenterView(m.width, m.height, panel)213+ return ui.CenterView(width, height, panel)212214 }
R app/state_view.go → app/view_helpers.go +12 -12
@@ -6,16 +6,16 @@ import (
66 "charm.land/lipgloss/v2"77 )88 9-func (m model) renderContinueView() string {10- if len(m.cont.games) == 0 {11- return m.renderPanel("Saved Games", "No saved games yet.", "esc back")9+func renderContinueView(cont continueState, width, height int, notice noticeState) string {10+ if len(cont.games) == 0 {11+ return renderPanelView(width, height, notice, "Saved Games", "No saved games yet.", "esc back")1212 }1313 1414 footer := "↑/↓ navigate • enter resume • esc back"15- if pg := ui.TablePagination(m.cont.table); pg != "" {15+ if pg := ui.TablePagination(cont.table); pg != "" {1616 footer = pg + " " + footer1717 }18- return m.renderPanel("Saved Games", m.cont.table.View(), footer)18+ return renderPanelView(width, height, notice, "Saved Games", cont.table.View(), footer)1919 }2020 2121 func (m model) renderGameView() string {
@@ -34,19 +34,19 @@ func (m model) renderGameView() string {
3434 return ui.CenterView(m.width, m.height, m.appendNotice(centered))3535 }3636 37-func (m model) renderStatsView() string {38- statsWidth, _ := statsViewportSize(m.width, m.height, m.stats.cards)37+func renderStatsView(stats statsState, width, height int, notice noticeState) string {38+ statsWidth, _ := statsViewportSize(width, height, stats.cards)3939 var statsBody string40- if len(m.stats.cards) == 0 {41- statsBody = m.stats.viewport.View()40+ if len(stats.cards) == 0 {41+ statsBody = stats.viewport.View()4242 } else {43- banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)43+ banner := ui.RenderStatsBanner(stats.profile, statsWidth)4444 statsBody = lipgloss.JoinVertical(lipgloss.Left,4545 banner,4646 "",47- m.stats.viewport.View(),47+ stats.viewport.View(),4848 )4949 }5050 statsBody = lipgloss.NewStyle().Width(statsWidth).Render(statsBody)51- return m.renderPanel("Stats", statsBody, "↑/↓ scroll • esc back")51+ return renderPanelView(width, height, notice, "Stats", statsBody, "↑/↓ scroll • esc back")5252 }
M app/view_test.go +1 -0
M app/weekly.go +21 -12
@@ -37,11 +37,12 @@ func (r weeklyRow) tableRow() table.Row {
3737 }3838 }3939 40-func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {40+func (m model) enterWeeklyView() (model, tea.Cmd) {4141 current := weekly.Current(time.Now())4242 m.weekly.cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)4343 m = m.refreshWeeklyBrowser()4444 m.state = weeklyView45+ m = m.initScreen(weeklyView)4546 return m, nil4647 }4748
@@ -70,23 +71,30 @@ func (m model) refreshWeeklyBrowser() model {
7071 }7172 7273 func (m model) isCurrentWeeklySelection() bool {74+ return isCurrentWeeklySelection(m.weekly.cursor)75+}76+77+func isCurrentWeeklySelection(cursor time.Time) bool {7378 current := weekly.Current(time.Now())74- year, weekNumber := m.selectedWeek()79+ year, weekNumber := resolvedWeekCursor(cursor).ISOWeek()7580 return current.Year == year && current.Week == weekNumber7681 }7782 78-func (m model) selectedWeek() (int, int) {79- cursor := m.weekly.cursor83+func weeklyPanelTitle(cursor time.Time) string {84+ year, weekNumber := resolvedWeekCursor(cursor).ISOWeek()85+ return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)86+}87+88+func resolvedWeekCursor(cursor time.Time) time.Time {8089 if cursor.IsZero() {8190 current := weekly.Current(time.Now())82- cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)91+ return weekly.StartOfWeek(current.Year, current.Week, time.Local)8392 }84- return cursor.ISOWeek()93+ return cursor8594 }8695 87-func (m model) weeklyPanelTitle() string {88- year, weekNumber := m.selectedWeek()89- return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)96+func (m model) selectedWeek() (int, int) {97+ return resolvedWeekCursor(m.weekly.cursor).ISOWeek()9098 }9199 92100 func (m model) moveWeeklyWeek(delta int) model {
@@ -106,7 +114,7 @@ func (m model) moveWeeklyWeek(delta int) model {
106114 return m.refreshWeeklyBrowser()107115 }108116 109-func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {117+func (m model) handleWeeklyEnter() (model, tea.Cmd) {110118 idx := m.weekly.table.Cursor()111119 if idx < 0 || idx >= len(m.weekly.rows) {112120 return m, nil
@@ -115,7 +123,7 @@ func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {
115123 return m.openWeeklyRow(m.weekly.rows[idx])116124 }117125 118-func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {126+func (m model) openWeeklyRow(row weeklyRow) (model, tea.Cmd) {119127 info, ok := weekly.ParseName(row.Name)120128 if !ok {121129 return m, nil
@@ -182,12 +190,13 @@ func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {
182190 m.weekly.cursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)183191 m = m.refreshWeeklyBrowser()184192 m.state = weeklyView193+ m = m.initScreen(weeklyView)185194 if len(m.weekly.rows) == 0 || !m.weekly.rows[0].Playable {186195 return m, nil, true187196 }188197 189198 next, cmd := m.openWeeklyRow(m.weekly.rows[0])190- return next.(model), cmd, true199+ return next, cmd, true191200 }192201 193202 func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {
M architecture_test.go +5 -5
@@ -17,7 +17,7 @@ func TestPuzzlePackageStaysPure(t *testing.T) {
1717 "charm.land/bubbles",1818 "charm.land/lipgloss",1919 "github.com/FelineStateMachine/puzzletea/theme",20- "github.com/FelineStateMachine/puzzletea/pdfexport",20+ "github.com/FelineStateMachine/puzzletea/export/pdf",2121 })2222 }2323
@@ -40,7 +40,7 @@ func TestStorePackageDoesNotImportSchedulePackages(t *testing.T) {
4040 4141 func TestGamePackageDoesNotImportPDFExport(t *testing.T) {4242 assertPackageDoesNotImport(t, "game", []string{43- "github.com/FelineStateMachine/puzzletea/pdfexport",43+ "github.com/FelineStateMachine/puzzletea/export/pdf",4444 })4545 }4646
@@ -49,7 +49,7 @@ func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {
4949 }5050 5151 func TestBuiltinPrintDoesNotImportConcreteGames(t *testing.T) {52- assertPackageDoesNotImport(t, "builtinprint", concreteGameImportPaths(t))52+ assertPackageDoesNotImport(t, "export/builtinprint", concreteGameImportPaths(t))5353 }5454 5555 func TestSessionPackageDoesNotUseNameDerivedRunMetadata(t *testing.T) {
@@ -101,7 +101,7 @@ func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {
101101 func concreteGameImportPaths(t testing.TB) []string {102102 t.Helper()103103 104- pattern := filepath.Join(repoRoot(t), "*", "gamemode.go")104+ pattern := filepath.Join(repoRoot(t), "games", "*", "gamemode.go")105105 matches, err := filepath.Glob(pattern)106106 if err != nil {107107 t.Fatalf("glob concrete game packages: %v", err)
@@ -110,7 +110,7 @@ func concreteGameImportPaths(t testing.TB) []string {
110110 importPaths := make([]string, 0, len(matches))111111 for _, match := range matches {112112 dir := filepath.Base(filepath.Dir(match))113- importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/"+dir)113+ importPaths = append(importPaths, "github.com/FelineStateMachine/puzzletea/games/"+dir)114114 }115115 slices.Sort(importPaths)116116 return importPaths
M cmd/export.go +1 -1
M cmd/export_pdf.go +3 -3
M cmd/export_pdf_test.go +1 -1
M cmd/export_test.go +1 -1
M cmd/new_export.go +3 -3
M cmd/new_export_test.go +1 -1
M cmd/test.go +1 -1
R builtinprint/register.go → export/builtinprint/register.go +1 -1
R builtinprint/register_test.go → export/builtinprint/register_test.go +1 -1
R packexport/packexport_test.go → export/pack/packexport_test.go +1 -1
R packexport/render.go → export/pack/render.go +1 -1
R packexport/runtime.go → export/pack/runtime.go +1 -1
R packexport/spec.go → export/pack/spec.go +2 -2
R pdfexport/font.go → export/pdf/font.go +0 -0

No content changes.

R pdfexport/font_test.go → export/pdf/font_test.go +0 -0

No content changes.

R pdfexport/fonts.go → export/pdf/fonts.go +0 -0

No content changes.

R pdfexport/fonts/AtkinsonHyperlegibleNext-Bold.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Bold.ttf +0 -0

No content changes.

R pdfexport/fonts/AtkinsonHyperlegibleNext-OFL.txt → export/pdf/fonts/AtkinsonHyperlegibleNext-OFL.txt +0 -0

No content changes.

R pdfexport/fonts/AtkinsonHyperlegibleNext-Regular.ttf → export/pdf/fonts/AtkinsonHyperlegibleNext-Regular.ttf +0 -0

No content changes.

R pdfexport/fonts/DMSerifDisplay-OFL.txt → export/pdf/fonts/DMSerifDisplay-OFL.txt +0 -0

No content changes.

R pdfexport/fonts/DMSerifDisplay-Regular.ttf → export/pdf/fonts/DMSerifDisplay-Regular.ttf +0 -0

No content changes.

R pdfexport/jsonl.go → export/pdf/jsonl.go +0 -0

No content changes.

R pdfexport/jsonl_test.go → export/pdf/jsonl_test.go +0 -0

No content changes.

R pdfexport/normalize.go → export/pdf/normalize.go +0 -0

No content changes.

R pdfexport/order.go → export/pdf/order.go +0 -0

No content changes.

R pdfexport/order_test.go → export/pdf/order_test.go +0 -0

No content changes.

R pdfexport/parse_fillomino.go → export/pdf/parse_fillomino.go +0 -0

No content changes.

R pdfexport/parse_hashi.go → export/pdf/parse_hashi.go +0 -0

No content changes.

R pdfexport/parse_helpers.go → export/pdf/parse_helpers.go +0 -0

No content changes.

R pdfexport/parse_hitori.go → export/pdf/parse_hitori.go +0 -0

No content changes.

R pdfexport/parse_netwalk.go → export/pdf/parse_netwalk.go +0 -0

No content changes.

R pdfexport/parse_nonogram.go → export/pdf/parse_nonogram.go +0 -0

No content changes.

R pdfexport/parse_nurikabe.go → export/pdf/parse_nurikabe.go +0 -0

No content changes.

R pdfexport/parse_rippleeffect.go → export/pdf/parse_rippleeffect.go +0 -0

No content changes.

R pdfexport/parse_shikaku.go → export/pdf/parse_shikaku.go +0 -0

No content changes.

R pdfexport/parse_sudoku.go → export/pdf/parse_sudoku.go +0 -0

No content changes.

R pdfexport/parse_takuzu.go → export/pdf/parse_takuzu.go +0 -0

No content changes.

R pdfexport/parse_takuzuplus.go → export/pdf/parse_takuzuplus.go +0 -0

No content changes.

R pdfexport/parse_wordsearch.go → export/pdf/parse_wordsearch.go +0 -0

No content changes.

R pdfexport/print_adapter.go → export/pdf/print_adapter.go +0 -0

No content changes.

R pdfexport/printdata_test.go → export/pdf/printdata_test.go +0 -0

No content changes.

R pdfexport/render.go → export/pdf/render.go +0 -0

No content changes.

R pdfexport/render_cover.go → export/pdf/render_cover.go +0 -0

No content changes.

R pdfexport/render_cover_test.go → export/pdf/render_cover_test.go +0 -0

No content changes.

R pdfexport/render_difficulty.go → export/pdf/render_difficulty.go +0 -0

No content changes.

R pdfexport/render_difficulty_test.go → export/pdf/render_difficulty_test.go +0 -0

No content changes.

R pdfexport/render_error_test.go → export/pdf/render_error_test.go +0 -0

No content changes.

R pdfexport/render_instructions.go → export/pdf/render_instructions.go +0 -0

No content changes.

R pdfexport/render_instructions_test.go → export/pdf/render_instructions_test.go +0 -0

No content changes.

R pdfexport/render_kit.go → export/pdf/render_kit.go +0 -0

No content changes.

R pdfexport/render_layout_test.go → export/pdf/render_layout_test.go +0 -0

No content changes.

R pdfexport/render_metadata.go → export/pdf/render_metadata.go +0 -0

No content changes.

R pdfexport/render_plan.go → export/pdf/render_plan.go +0 -0

No content changes.

R pdfexport/render_title.go → export/pdf/render_title.go +0 -0

No content changes.

R pdfexport/render_title_test.go → export/pdf/render_title_test.go +0 -0

No content changes.

R pdfexport/render_tokens.go → export/pdf/render_tokens.go +0 -0

No content changes.

R pdfexport/types.go → export/pdf/types.go +0 -0

No content changes.

M gameentry/gameentry.go +1 -1
R fillomino/README.md → games/fillomino/README.md +0 -0

No content changes.

R fillomino/export.go → games/fillomino/export.go +0 -0

No content changes.

R fillomino/fillomino_test.go → games/fillomino/fillomino_test.go +0 -0

No content changes.

R fillomino/gamemode.go → games/fillomino/gamemode.go +0 -0

No content changes.

R fillomino/generator.go → games/fillomino/generator.go +0 -0

No content changes.

R fillomino/grid.go → games/fillomino/grid.go +0 -0

No content changes.

R fillomino/help.md → games/fillomino/help.md +0 -0

No content changes.

R fillomino/keys.go → games/fillomino/keys.go +0 -0

No content changes.

R fillomino/model.go → games/fillomino/model.go +0 -0

No content changes.

R fillomino/mouse.go → games/fillomino/mouse.go +0 -0

No content changes.

R fillomino/print_adapter.go → games/fillomino/print_adapter.go +1 -1
R fillomino/print_adapter_test.go → games/fillomino/print_adapter_test.go +0 -0

No content changes.

R fillomino/solver.go → games/fillomino/solver.go +0 -0

No content changes.

R fillomino/style.go → games/fillomino/style.go +0 -0

No content changes.

R fillomino/testdata/visual_states.jsonl → games/fillomino/testdata/visual_states.jsonl +0 -0

No content changes.

R hashiwokakero/README.md → games/hashiwokakero/README.md +0 -0

No content changes.

R hashiwokakero/benchmark_test.go → games/hashiwokakero/benchmark_test.go +0 -0

No content changes.

R hashiwokakero/export.go → games/hashiwokakero/export.go +0 -0

No content changes.

R hashiwokakero/gamemode.go → games/hashiwokakero/gamemode.go +0 -0

No content changes.

R hashiwokakero/generation_invariants_test.go → games/hashiwokakero/generation_invariants_test.go +0 -0

No content changes.

R hashiwokakero/generator.go → games/hashiwokakero/generator.go +0 -0

No content changes.

R hashiwokakero/hashiwokakero_test.go → games/hashiwokakero/hashiwokakero_test.go +0 -0

No content changes.

R hashiwokakero/help.md → games/hashiwokakero/help.md +0 -0

No content changes.

R hashiwokakero/island.go → games/hashiwokakero/island.go +0 -0

No content changes.

R hashiwokakero/keys.go → games/hashiwokakero/keys.go +0 -0

No content changes.

R hashiwokakero/model.go → games/hashiwokakero/model.go +0 -0

No content changes.

R hashiwokakero/mouse.go → games/hashiwokakero/mouse.go +0 -0

No content changes.

R hashiwokakero/print_adapter.go → games/hashiwokakero/print_adapter.go +1 -1
R hashiwokakero/style.go → games/hashiwokakero/style.go +0 -0

No content changes.

R hashiwokakero/testdata/visual_states.jsonl → games/hashiwokakero/testdata/visual_states.jsonl +0 -0

No content changes.

R hitori/README.md → games/hitori/README.md +0 -0

No content changes.

R hitori/benchmark_test.go → games/hitori/benchmark_test.go +0 -0

No content changes.

R hitori/export.go → games/hitori/export.go +0 -0

No content changes.

R hitori/gamemode.go → games/hitori/gamemode.go +0 -0

No content changes.

R hitori/generator.go → games/hitori/generator.go +0 -0

No content changes.

R hitori/grid.go → games/hitori/grid.go +0 -0

No content changes.

R hitori/help.md → games/hitori/help.md +0 -0

No content changes.

R hitori/hitori_test.go → games/hitori/hitori_test.go +0 -0

No content changes.

R hitori/keys.go → games/hitori/keys.go +0 -0

No content changes.

R hitori/model.go → games/hitori/model.go +0 -0

No content changes.

R hitori/mouse.go → games/hitori/mouse.go +0 -0

No content changes.

R hitori/print_adapter.go → games/hitori/print_adapter.go +1 -1
R hitori/solver.go → games/hitori/solver.go +0 -0

No content changes.

R hitori/style.go → games/hitori/style.go +0 -0

No content changes.

R hitori/testdata/visual_states.jsonl → games/hitori/testdata/visual_states.jsonl +0 -0

No content changes.

R lightsout/README.md → games/lightsout/README.md +0 -0

No content changes.

R lightsout/benchmark_test.go → games/lightsout/benchmark_test.go +0 -0

No content changes.

R lightsout/export.go → games/lightsout/export.go +0 -0

No content changes.

R lightsout/gamemode.go → games/lightsout/gamemode.go +0 -0

No content changes.

R lightsout/generator.go → games/lightsout/generator.go +0 -0

No content changes.

R lightsout/help.md → games/lightsout/help.md +0 -0

No content changes.

R lightsout/keys.go → games/lightsout/keys.go +0 -0

No content changes.

R lightsout/lightsout_test.go → games/lightsout/lightsout_test.go +0 -0

No content changes.

R lightsout/model.go → games/lightsout/model.go +0 -0

No content changes.

R lightsout/style.go → games/lightsout/style.go +0 -0

No content changes.

R lightsout/testdata/visual_states.jsonl → games/lightsout/testdata/visual_states.jsonl +0 -0

No content changes.

R netwalk/README.md → games/netwalk/README.md +0 -0

No content changes.

R netwalk/export.go → games/netwalk/export.go +0 -0

No content changes.

R netwalk/gamemode.go → games/netwalk/gamemode.go +0 -0

No content changes.

R netwalk/generator.go → games/netwalk/generator.go +0 -0

No content changes.

R netwalk/grid.go → games/netwalk/grid.go +0 -0

No content changes.

R netwalk/help.md → games/netwalk/help.md +0 -0

No content changes.

R netwalk/keys.go → games/netwalk/keys.go +0 -0

No content changes.

R netwalk/model.go → games/netwalk/model.go +0 -0

No content changes.

R netwalk/netwalk_test.go → games/netwalk/netwalk_test.go +0 -0

No content changes.

R netwalk/print_adapter.go → games/netwalk/print_adapter.go +1 -1
R netwalk/style.go → games/netwalk/style.go +0 -0

No content changes.

R netwalk/testdata/visual_states.jsonl → games/netwalk/testdata/visual_states.jsonl +0 -0

No content changes.

R nonogram/README.md → games/nonogram/README.md +0 -0

No content changes.

R nonogram/dynamic_grid_test.go → games/nonogram/dynamic_grid_test.go +0 -0

No content changes.

R nonogram/export.go → games/nonogram/export.go +0 -0

No content changes.

R nonogram/gamemode.go → games/nonogram/gamemode.go +0 -0

No content changes.

R nonogram/generator.go → games/nonogram/generator.go +0 -0

No content changes.

R nonogram/grid.go → games/nonogram/grid.go +0 -0

No content changes.

R nonogram/help.md → games/nonogram/help.md +0 -0

No content changes.

R nonogram/keys.go → games/nonogram/keys.go +0 -0

No content changes.

R nonogram/model.go → games/nonogram/model.go +0 -0

No content changes.

R nonogram/mouse.go → games/nonogram/mouse.go +0 -0

No content changes.

R nonogram/nonogram_test.go → games/nonogram/nonogram_test.go +0 -0

No content changes.

R nonogram/print_adapter.go → games/nonogram/print_adapter.go +1 -1
R nonogram/print_adapter_test.go → games/nonogram/print_adapter_test.go +1 -1
R nonogram/style.go → games/nonogram/style.go +0 -0

No content changes.

R nonogram/testdata/visual_states.jsonl → games/nonogram/testdata/visual_states.jsonl +0 -0

No content changes.

R nonogram/tomography.go → games/nonogram/tomography.go +0 -0

No content changes.

R nurikabe/README.md → games/nurikabe/README.md +0 -0

No content changes.

R nurikabe/export.go → games/nurikabe/export.go +0 -0

No content changes.

R nurikabe/gamemode.go → games/nurikabe/gamemode.go +0 -0

No content changes.

R nurikabe/generator.go → games/nurikabe/generator.go +0 -0

No content changes.

R nurikabe/grid.go → games/nurikabe/grid.go +0 -0

No content changes.

R nurikabe/help.md → games/nurikabe/help.md +0 -0

No content changes.

R nurikabe/keys.go → games/nurikabe/keys.go +0 -0

No content changes.

R nurikabe/model.go → games/nurikabe/model.go +0 -0

No content changes.

R nurikabe/mouse.go → games/nurikabe/mouse.go +0 -0

No content changes.

R nurikabe/nurikabe_test.go → games/nurikabe/nurikabe_test.go +0 -0

No content changes.

R nurikabe/print_adapter.go → games/nurikabe/print_adapter.go +1 -1
R nurikabe/solver.go → games/nurikabe/solver.go +0 -0

No content changes.

R nurikabe/style.go → games/nurikabe/style.go +0 -0

No content changes.

R nurikabe/testdata/visual_states.jsonl → games/nurikabe/testdata/visual_states.jsonl +0 -0

No content changes.

R rippleeffect/README.md → games/rippleeffect/README.md +3 -3
R rippleeffect/export.go → games/rippleeffect/export.go +0 -0

No content changes.

R rippleeffect/gamemode.go → games/rippleeffect/gamemode.go +31 -26
@@ -64,10 +64,11 @@ var Modes = []game.Mode{
6464 3,6565 0.69,6666 generationProfile{67- cageWeights: []int{0, 5, 6, 3},68- frontierSamples: 3,69- shapeBias: shapeBiasCompact,70- minGivensByCage: []int{0, 1, 1, 2},67+ cageWeights: []int{0, 5, 6, 3},68+ frontierSamples: 3,69+ shapeBias: shapeBiasCompact,70+ minGivensByCage: []int{0, 1, 1, 2},71+ maxSingletonCages: -1,7172 },7273 ),7374 NewModeWithProfile(
@@ -77,49 +78,53 @@ var Modes = []game.Mode{
7778 3,7879 0.64,7980 generationProfile{80- cageWeights: []int{0, 3, 5, 4},81- frontierSamples: 3,82- shapeBias: shapeBiasCompact,83- minGivensByCage: []int{0, 1, 1, 2},81+ cageWeights: []int{0, 3, 5, 4},82+ frontierSamples: 3,83+ shapeBias: shapeBiasCompact,84+ minGivensByCage: []int{0, 1, 1, 2},85+ maxSingletonCages: -1,8486 },8587 ),8688 NewModeWithProfile(8789 "Medium 7x7",88- "Mixed cage shapes with a balanced clue spread.",90+ "Mixed cage shapes with sparser anchors and roomier deductions.",8991 7,9092 4,91- 0.59,93+ 0.50,9294 generationProfile{93- cageWeights: []int{0, 2, 4, 5, 3},94- frontierSamples: 2,95- shapeBias: shapeBiasNeutral,96- minGivensByCage: []int{0, 1, 1, 1, 1},95+ cageWeights: []int{0, 0, 3, 5, 4},96+ frontierSamples: 2,97+ shapeBias: shapeBiasNeutral,98+ minGivensByCage: []int{0, 0, 0, 0, 0},99+ maxSingletonCages: 1,97100 },98101 ),99102 NewModeWithProfile(100103 "Hard 8x8",101- "Longer cages and lighter anchors create broader ripple scans.",104+ "Longer cages and sparse anchors reward broader ripple reads.",102105 8,103106 4,104- 0.55,107+ 0.46,105108 generationProfile{106- cageWeights: []int{0, 1, 2, 4, 5},107- frontierSamples: 3,108- shapeBias: shapeBiasWinding,109- minGivensByCage: []int{0, 1, 1, 1, 1},109+ cageWeights: []int{0, 0, 1, 4, 5},110+ frontierSamples: 3,111+ shapeBias: shapeBiasWinding,112+ minGivensByCage: []int{0, 0, 0, 0, 0},113+ maxSingletonCages: 1,110114 },111115 ),112116 NewModeWithProfile(113117 "Expert 9x9",114- "Sparser anchors and winding large cages push global deductions.",118+ "Winding large cages and sparse anchors push global deductions.",115119 9,116120 5,117- 0.51,121+ 0.42,118122 generationProfile{119- cageWeights: []int{0, 1, 1, 3, 4, 5},120- frontierSamples: 4,121- shapeBias: shapeBiasWinding,122- minGivensByCage: []int{0, 1, 1, 1, 1, 1},123+ cageWeights: []int{0, 0, 1, 2, 4, 5},124+ frontierSamples: 4,125+ shapeBias: shapeBiasWinding,126+ minGivensByCage: []int{0, 0, 0, 0, 0, 0},127+ maxSingletonCages: 1,123128 },124129 ),125130 }
R rippleeffect/generator.go → games/rippleeffect/generator.go +156 -11
@@ -22,10 +22,11 @@ const (
2222 )2323 2424 type generationProfile struct {25- cageWeights []int26- frontierSamples int27- shapeBias shapeBias28- minGivensByCage []int25+ cageWeights []int26+ frontierSamples int27+ shapeBias shapeBias28+ minGivensByCage []int29+ maxSingletonCages int2930 }3031 3132 func GeneratePuzzle(width, height, maxCage int, givenRatio float64) (Puzzle, error) {
@@ -38,10 +39,11 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
3839 }3940 4041 func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio float64, profile generationProfile, rng *rand.Rand) (Puzzle, error) {41- const maxAttempts = 2442+ const maxAttempts = 484243 profile = profile.withDefaults(maxCage)4344 for range maxAttempts {4445 cages := generateCages(width, height, maxCage, profile, rng)46+ cages = normalizeSingletonCages(width, height, maxCage, cages, profile, rng)4547 geo, err := buildGeometry(width, height, cages)4648 if err != nil {4749 continue
@@ -56,6 +58,12 @@ func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio floa
5658 if countSolutions(geo, givens, 2) != 1 {5759 continue5860 }61+ if hasOversizeCage(cages, maxCage) {62+ continue63+ }64+ if profile.maxSingletonCages >= 0 && countSingletonCages(cages) > profile.maxSingletonCages {65+ continue66+ }5967 6068 return Puzzle{6169 Width: width,
@@ -71,10 +79,11 @@ func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio floa
7179 7280 func defaultGenerationProfile(maxCage int) generationProfile {7381 return generationProfile{74- cageWeights: defaultCageWeights(maxCage),75- frontierSamples: 1,76- shapeBias: shapeBiasNeutral,77- minGivensByCage: make([]int, maxCage+1),82+ cageWeights: defaultCageWeights(maxCage),83+ frontierSamples: 1,84+ shapeBias: shapeBiasNeutral,85+ minGivensByCage: make([]int, maxCage+1),86+ maxSingletonCages: -1,7887 }7988 }8089
@@ -112,6 +121,142 @@ func growIntSlice(values []int, size int) []int {
112121 return grown113122 }114123 124+func normalizeSingletonCages(width, height, maxCage int, cages []Cage, profile generationProfile, rng *rand.Rand) []Cage {125+ if profile.maxSingletonCages < 0 {126+ return cages127+ }128+129+ normalized := append([]Cage(nil), cages...)130+ for countSingletonCages(normalized) > profile.maxSingletonCages {131+ singletons := singletonCageIndices(normalized)132+ if len(singletons) == 0 {133+ break134+ }135+ singletonIdx := singletons[rng.IntN(len(singletons))]136+ targetIdx, ok := chooseSingletonMergeTarget(width, height, maxCage, normalized, singletonIdx, rng)137+ if !ok {138+ break139+ }140+141+ cell := normalized[singletonIdx].Cells[0]142+ normalized[targetIdx].Cells = append(normalized[targetIdx].Cells, cell)143+ normalized[targetIdx].Size = len(normalized[targetIdx].Cells)144+ normalized = append(normalized[:singletonIdx], normalized[singletonIdx+1:]...)145+ }146+147+ return renumberCages(normalized)148+}149+150+func hasOversizeCage(cages []Cage, maxCage int) bool {151+ for _, cage := range cages {152+ if len(cage.Cells) > maxCage {153+ return true154+ }155+ }156+ return false157+}158+159+func countSingletonCages(cages []Cage) int {160+ count := 0161+ for _, cage := range cages {162+ if len(cage.Cells) == 1 {163+ count++164+ }165+ }166+ return count167+}168+169+func singletonCageIndices(cages []Cage) []int {170+ indices := make([]int, 0)171+ for idx, cage := range cages {172+ if len(cage.Cells) == 1 {173+ indices = append(indices, idx)174+ }175+ }176+ return indices177+}178+179+func chooseSingletonMergeTarget(width, height, maxCage int, cages []Cage, singletonIdx int, rng *rand.Rand) (int, bool) {180+ singleton := cages[singletonIdx]181+ if len(singleton.Cells) != 1 {182+ return 0, false183+ }184+185+ cageGrid := buildCageIndexGrid(width, height, cages)186+ cell := singleton.Cells[0]187+ neighbors := make([]int, 0, 4)188+ seen := make(map[int]struct{}, 4)189+ for _, next := range orthogonalNeighbors(point{x: cell.X, y: cell.Y}, width, height) {190+ cageIdx := cageGrid[next.y][next.x]191+ if cageIdx < 0 || cageIdx == singletonIdx {192+ continue193+ }194+ if _, exists := seen[cageIdx]; exists {195+ continue196+ }197+ seen[cageIdx] = struct{}{}198+ neighbors = append(neighbors, cageIdx)199+ }200+ if len(neighbors) == 0 {201+ return 0, false202+ }203+204+ allowed := make([]int, 0, len(neighbors))205+ for _, idx := range neighbors {206+ if len(cages[idx].Cells)+1 <= maxCage {207+ allowed = append(allowed, idx)208+ }209+ }210+ if len(allowed) > 0 {211+ neighbors = allowed212+ }213+214+ minSize := len(cages[neighbors[0]].Cells)215+ best := make([]int, 0, len(neighbors))216+ for _, idx := range neighbors {217+ size := len(cages[idx].Cells)218+ if size < minSize {219+ minSize = size220+ best = best[:0]221+ }222+ if size == minSize {223+ best = append(best, idx)224+ }225+ }226+227+ return best[rng.IntN(len(best))], true228+}229+230+func buildCageIndexGrid(width, height int, cages []Cage) [][]int {231+ grid := make([][]int, height)232+ for y := range height {233+ grid[y] = make([]int, width)234+ for x := range width {235+ grid[y][x] = -1236+ }237+ }238+239+ for cageIdx, cage := range cages {240+ for _, cell := range cage.Cells {241+ grid[cell.Y][cell.X] = cageIdx242+ }243+ }244+245+ return grid246+}247+248+func renumberCages(cages []Cage) []Cage {249+ renumbered := make([]Cage, len(cages))250+ for idx, cage := range cages {251+ renumbered[idx] = Cage{252+ ID: idx,253+ Size: len(cage.Cells),254+ Cells: append([]Cell(nil), cage.Cells...),255+ }256+ }257+ return renumbered258+}259+115260 func generateCages(width, height, maxCage int, profile generationProfile, rng *rand.Rand) []Cage {116261 assigned := make([][]bool, height)117262 for y := range height {
@@ -394,7 +539,7 @@ func minGivensForCage(profile generationProfile, cageSize int) int {
394539 return 0395540 }396541 if cageSize >= len(profile.minGivensByCage) {397- return max(1, profile.minGivensByCage[len(profile.minGivensByCage)-1])542+ return max(0, min(profile.minGivensByCage[len(profile.minGivensByCage)-1], cageSize))398543 }399- return max(1, min(profile.minGivensByCage[cageSize], cageSize))544+ return max(0, min(profile.minGivensByCage[cageSize], cageSize))400545 }
R rippleeffect/geometry.go → games/rippleeffect/geometry.go +0 -0

No content changes.

R rippleeffect/grid.go → games/rippleeffect/grid.go +0 -0

No content changes.

R rippleeffect/help.md → games/rippleeffect/help.md +0 -0

No content changes.

R rippleeffect/keys.go → games/rippleeffect/keys.go +0 -0

No content changes.

R rippleeffect/model.go → games/rippleeffect/model.go +0 -0

No content changes.

R rippleeffect/print_adapter.go → games/rippleeffect/print_adapter.go +1 -1
R rippleeffect/rippleeffect_test.go → games/rippleeffect/rippleeffect_test.go +97 -0
@@ -187,6 +187,87 @@ func TestGeneratePuzzleSeeded(t *testing.T) {
187187 }188188 }189189 190+func TestMinGivensForCageAllowsZero(t *testing.T) {191+ profile := generationProfile{minGivensByCage: []int{0, 0, 0, 0}}192+193+ if got := minGivensForCage(profile, 1); got != 0 {194+ t.Fatalf("minGivensForCage(..., 1) = %d, want 0", got)195+ }196+ if got := minGivensForCage(profile, 3); got != 0 {197+ t.Fatalf("minGivensForCage(..., 3) = %d, want 0", got)198+ }199+}200+201+func TestNormalizeSingletonCagesCapsSingletons(t *testing.T) {202+ rng := rand.New(rand.NewPCG(77, 99))203+ cages := []Cage{204+ {ID: 0, Size: 1, Cells: []Cell{{0, 0}}},205+ {ID: 1, Size: 1, Cells: []Cell{{1, 0}}},206+ {ID: 2, Size: 2, Cells: []Cell{{2, 0}, {2, 1}}},207+ {ID: 3, Size: 2, Cells: []Cell{{0, 1}, {0, 2}}},208+ {ID: 4, Size: 3, Cells: []Cell{{1, 1}, {1, 2}, {2, 2}}},209+ }210+211+ normalized := normalizeSingletonCages(3, 3, 3, cages, generationProfile{maxSingletonCages: 1}, rng)212+ if got := countSingletonCages(normalized); got > 1 {213+ t.Fatalf("singleton cages = %d, want <= 1", got)214+ }215+216+ geo, err := buildGeometry(3, 3, normalized)217+ if err != nil {218+ t.Fatalf("buildGeometry() after normalization: %v", err)219+ }220+ if got := len(geo.cages); got != len(normalized) {221+ t.Fatalf("geometry cages = %d, want %d", got, len(normalized))222+ }223+}224+225+func TestSparseModesGenerateUniquePuzzlesWithLimitedSingletons(t *testing.T) {226+ sampleCounts := map[int]int{227+ 2: 6,228+ 3: 6,229+ // Expert 9x9 has much costlier seeded generation paths, so one230+ // deterministic sample is enough to cover the post-generation invariants.231+ 4: 1,232+ }233+ sawZeroGivenCage := false234+235+ for _, idx := range []int{2, 3, 4} {236+ mode := Modes[idx].(Mode)237+238+ for sample := range sampleCounts[idx] {239+ rng := rand.New(rand.NewPCG(uint64(7000+idx*31+sample), uint64(8000+idx*37+sample)))240+ puzzle, err := mode.generatePuzzleSeeded(rng)241+ if err != nil {242+ t.Fatalf("%s generatePuzzleSeeded failed: %v", mode.Title(), err)243+ }244+245+ geo, err := buildGeometry(puzzle.Width, puzzle.Height, puzzle.Cages)246+ if err != nil {247+ t.Fatalf("%s buildGeometry failed: %v", mode.Title(), err)248+ }249+ if got := countSolutions(geo, puzzle.Givens, 2); got != 1 {250+ t.Fatalf("%s solutions = %d, want 1", mode.Title(), got)251+ }252+253+ if got := countSingletonCages(puzzle.Cages); got > 1 {254+ t.Fatalf("%s singleton cages = %d, want <= 1", mode.Title(), got)255+ }256+ if hasOversizeCage(puzzle.Cages, mode.MaxCage) {257+ t.Fatalf("%s generated a cage larger than max size %d", mode.Title(), mode.MaxCage)258+ }259+260+ if countZeroGivenCages(puzzle) > 0 {261+ sawZeroGivenCage = true262+ }263+ }264+ }265+266+ if !sawZeroGivenCage {267+ t.Fatal("expected sampled medium+ puzzles to include at least one zero-given cage")268+ }269+}270+190271 func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {191272 if testing.Short() {192273 t.Skip("skipping sampled ripple given-value distribution test in short mode")
@@ -688,3 +769,19 @@ func formatValueDistribution(counts []int) string {
688769 }689770 return strings.Join(parts, ", ")690771 }772+773+func countZeroGivenCages(puzzle Puzzle) int {774+ count := 0775+ for _, cage := range puzzle.Cages {776+ givens := 0777+ for _, cell := range cage.Cells {778+ if puzzle.Givens[cell.Y][cell.X] != 0 {779+ givens++780+ }781+ }782+ if givens == 0 {783+ count++784+ }785+ }786+ return count787+}
R rippleeffect/solver.go → games/rippleeffect/solver.go +0 -0

No content changes.

R rippleeffect/style.go → games/rippleeffect/style.go +0 -0

No content changes.

R rippleeffect/testdata/visual_states.jsonl → games/rippleeffect/testdata/visual_states.jsonl +0 -0

No content changes.

R shikaku/README.md → games/shikaku/README.md +0 -0

No content changes.

R shikaku/benchmark_test.go → games/shikaku/benchmark_test.go +0 -0

No content changes.

R shikaku/export.go → games/shikaku/export.go +0 -0

No content changes.

R shikaku/gamemode.go → games/shikaku/gamemode.go +0 -0

No content changes.

R shikaku/generation_invariants_test.go → games/shikaku/generation_invariants_test.go +0 -0

No content changes.

R shikaku/generator.go → games/shikaku/generator.go +0 -0

No content changes.

R shikaku/help.md → games/shikaku/help.md +0 -0

No content changes.

R shikaku/keys.go → games/shikaku/keys.go +0 -0

No content changes.

R shikaku/model.go → games/shikaku/model.go +0 -0

No content changes.

R shikaku/mouse.go → games/shikaku/mouse.go +0 -0

No content changes.

R shikaku/print_adapter.go → games/shikaku/print_adapter.go +1 -1
R shikaku/puzzle.go → games/shikaku/puzzle.go +0 -0

No content changes.

R shikaku/shikaku_test.go → games/shikaku/shikaku_test.go +0 -0

No content changes.

R shikaku/style.go → games/shikaku/style.go +0 -0

No content changes.

R shikaku/testdata/visual_states.jsonl → games/shikaku/testdata/visual_states.jsonl +0 -0

No content changes.

R spellpuzzle/README.md → games/spellpuzzle/README.md +0 -0

No content changes.

R spellpuzzle/board.go → games/spellpuzzle/board.go +0 -0

No content changes.

R spellpuzzle/export.go → games/spellpuzzle/export.go +0 -0

No content changes.

R spellpuzzle/gamemode.go → games/spellpuzzle/gamemode.go +0 -0

No content changes.

R spellpuzzle/generator.go → games/spellpuzzle/generator.go +0 -0

No content changes.

R spellpuzzle/help.md → games/spellpuzzle/help.md +0 -0

No content changes.

R spellpuzzle/keys.go → games/spellpuzzle/keys.go +0 -0

No content changes.

R spellpuzzle/lexicon.go → games/spellpuzzle/lexicon.go +0 -0

No content changes.

R spellpuzzle/model.go → games/spellpuzzle/model.go +0 -0

No content changes.

R spellpuzzle/print_adapter.go → games/spellpuzzle/print_adapter.go +1 -1
R spellpuzzle/print_adapter_test.go → games/spellpuzzle/print_adapter_test.go +1 -1
R spellpuzzle/spellpuzzle_test.go → games/spellpuzzle/spellpuzzle_test.go +0 -0

No content changes.

R spellpuzzle/style.go → games/spellpuzzle/style.go +0 -0

No content changes.

R spellpuzzle/twl06.txt → games/spellpuzzle/twl06.txt +0 -0

No content changes.

R sudoku/README.md → games/sudoku/README.md +0 -0

No content changes.

R sudoku/benchmark_test.go → games/sudoku/benchmark_test.go +0 -0

No content changes.

R sudoku/cell.go → games/sudoku/cell.go +0 -0

No content changes.

R sudoku/dynamic_board_test.go → games/sudoku/dynamic_board_test.go +0 -0

No content changes.

R sudoku/export.go → games/sudoku/export.go +0 -0

No content changes.

R sudoku/gamemode.go → games/sudoku/gamemode.go +0 -0

No content changes.

R sudoku/generator.go → games/sudoku/generator.go +0 -0

No content changes.

R sudoku/help.md → games/sudoku/help.md +0 -0

No content changes.

R sudoku/keys.go → games/sudoku/keys.go +0 -0

No content changes.

R sudoku/model.go → games/sudoku/model.go +0 -0

No content changes.

R sudoku/mouse.go → games/sudoku/mouse.go +0 -0

No content changes.

R sudoku/print_adapter.go → games/sudoku/print_adapter.go +1 -1
R sudoku/style.go → games/sudoku/style.go +0 -0

No content changes.

R sudoku/sudoku_test.go → games/sudoku/sudoku_test.go +0 -0

No content changes.

R sudoku/testdata/visual_states.jsonl → games/sudoku/testdata/visual_states.jsonl +0 -0

No content changes.

R sudoku/uniqueness_test.go → games/sudoku/uniqueness_test.go +0 -0

No content changes.

R sudokurgb/README.md → games/sudokurgb/README.md +0 -0

No content changes.

R sudokurgb/analysis.go → games/sudokurgb/analysis.go +0 -0

No content changes.

R sudokurgb/cell.go → games/sudokurgb/cell.go +0 -0

No content changes.

R sudokurgb/dynamic_board_test.go → games/sudokurgb/dynamic_board_test.go +0 -0

No content changes.

R sudokurgb/export.go → games/sudokurgb/export.go +0 -0

No content changes.

R sudokurgb/gamemode.go → games/sudokurgb/gamemode.go +0 -0

No content changes.

R sudokurgb/generator.go → games/sudokurgb/generator.go +0 -0

No content changes.

R sudokurgb/help.md → games/sudokurgb/help.md +0 -0

No content changes.

R sudokurgb/keys.go → games/sudokurgb/keys.go +0 -0

No content changes.

R sudokurgb/model.go → games/sudokurgb/model.go +0 -0

No content changes.

R sudokurgb/mouse.go → games/sudokurgb/mouse.go +0 -0

No content changes.

R sudokurgb/print_adapter.go → games/sudokurgb/print_adapter.go +1 -1
R sudokurgb/style.go → games/sudokurgb/style.go +0 -0

No content changes.

R sudokurgb/sudokurgb_test.go → games/sudokurgb/sudokurgb_test.go +0 -0

No content changes.

R sudokurgb/testdata/visual_states.jsonl → games/sudokurgb/testdata/visual_states.jsonl +0 -0

No content changes.

R takuzu/README.md → games/takuzu/README.md +0 -0

No content changes.

R takuzu/benchmark_test.go → games/takuzu/benchmark_test.go +0 -0

No content changes.

R takuzu/export.go → games/takuzu/export.go +0 -0

No content changes.

R takuzu/gamemode.go → games/takuzu/gamemode.go +0 -0

No content changes.

R takuzu/generator.go → games/takuzu/generator.go +0 -0

No content changes.

R takuzu/grid.go → games/takuzu/grid.go +0 -0

No content changes.

R takuzu/help.md → games/takuzu/help.md +0 -0

No content changes.

R takuzu/keys.go → games/takuzu/keys.go +0 -0

No content changes.

R takuzu/model.go → games/takuzu/model.go +0 -0

No content changes.

R takuzu/mouse.go → games/takuzu/mouse.go +0 -0

No content changes.

R takuzu/print_adapter.go → games/takuzu/print_adapter.go +1 -1
R takuzu/print_adapter_test.go → games/takuzu/print_adapter_test.go +1 -1
R takuzu/style.go → games/takuzu/style.go +0 -0

No content changes.

R takuzu/takuzu_test.go → games/takuzu/takuzu_test.go +0 -0

No content changes.

R takuzu/testdata/visual_states.jsonl → games/takuzu/testdata/visual_states.jsonl +0 -0

No content changes.

R takuzuplus/README.md → games/takuzuplus/README.md +0 -0

No content changes.

R takuzuplus/export.go → games/takuzuplus/export.go +0 -0

No content changes.

R takuzuplus/gamemode.go → games/takuzuplus/gamemode.go +0 -0

No content changes.

R takuzuplus/generator.go → games/takuzuplus/generator.go +1 -1
R takuzuplus/grid.go → games/takuzuplus/grid.go +1 -1
R takuzuplus/help.md → games/takuzuplus/help.md +0 -0

No content changes.

R takuzuplus/keys.go → games/takuzuplus/keys.go +0 -0

No content changes.

R takuzuplus/logic.go → games/takuzuplus/logic.go +1 -1
R takuzuplus/model.go → games/takuzuplus/model.go +1 -1
R takuzuplus/mouse.go → games/takuzuplus/mouse.go +0 -0

No content changes.

R takuzuplus/print_adapter.go → games/takuzuplus/print_adapter.go +2 -2
R takuzuplus/profile.go → games/takuzuplus/profile.go +0 -0

No content changes.

R takuzuplus/style.go → games/takuzuplus/style.go +0 -0

No content changes.

R takuzuplus/takuzuplus_test.go → games/takuzuplus/takuzuplus_test.go +0 -0

No content changes.

R takuzuplus/testdata/visual_states.jsonl → games/takuzuplus/testdata/visual_states.jsonl +0 -0

No content changes.

R wordsearch/README.md → games/wordsearch/README.md +0 -0

No content changes.

R wordsearch/benchmark_test.go → games/wordsearch/benchmark_test.go +0 -0

No content changes.

R wordsearch/export.go → games/wordsearch/export.go +0 -0

No content changes.

R wordsearch/gamemode.go → games/wordsearch/gamemode.go +0 -0

No content changes.

R wordsearch/generator.go → games/wordsearch/generator.go +0 -0

No content changes.

R wordsearch/grid.go → games/wordsearch/grid.go +0 -0

No content changes.

R wordsearch/help.md → games/wordsearch/help.md +0 -0

No content changes.

R wordsearch/keys.go → games/wordsearch/keys.go +0 -0

No content changes.

R wordsearch/model.go → games/wordsearch/model.go +0 -0

No content changes.

R wordsearch/mouse.go → games/wordsearch/mouse.go +0 -0

No content changes.

R wordsearch/print_adapter.go → games/wordsearch/print_adapter.go +1 -1
R wordsearch/style.go → games/wordsearch/style.go +0 -0

No content changes.

R wordsearch/word.go → games/wordsearch/word.go +0 -0

No content changes.

R wordsearch/words_dict.go → games/wordsearch/words_dict.go +0 -0

No content changes.

R wordsearch/wordsearch_test.go → games/wordsearch/wordsearch_test.go +0 -0

No content changes.

M internal/testjsonl/records.go +1 -1
M registry/registry.go +15 -15
@@ -6,24 +6,24 @@ import (
66 "fmt"77 88 "github.com/FelineStateMachine/puzzletea/catalog"9- "github.com/FelineStateMachine/puzzletea/fillomino"109 "github.com/FelineStateMachine/puzzletea/game"1110 "github.com/FelineStateMachine/puzzletea/gameentry"12- "github.com/FelineStateMachine/puzzletea/hashiwokakero"13- "github.com/FelineStateMachine/puzzletea/hitori"14- "github.com/FelineStateMachine/puzzletea/lightsout"15- "github.com/FelineStateMachine/puzzletea/netwalk"16- "github.com/FelineStateMachine/puzzletea/nonogram"17- "github.com/FelineStateMachine/puzzletea/nurikabe"11+ "github.com/FelineStateMachine/puzzletea/games/fillomino"12+ "github.com/FelineStateMachine/puzzletea/games/hashiwokakero"13+ "github.com/FelineStateMachine/puzzletea/games/hitori"14+ "github.com/FelineStateMachine/puzzletea/games/lightsout"15+ "github.com/FelineStateMachine/puzzletea/games/netwalk"16+ "github.com/FelineStateMachine/puzzletea/games/nonogram"17+ "github.com/FelineStateMachine/puzzletea/games/nurikabe"18+ "github.com/FelineStateMachine/puzzletea/games/rippleeffect"19+ "github.com/FelineStateMachine/puzzletea/games/shikaku"20+ "github.com/FelineStateMachine/puzzletea/games/spellpuzzle"21+ "github.com/FelineStateMachine/puzzletea/games/sudoku"22+ "github.com/FelineStateMachine/puzzletea/games/sudokurgb"23+ "github.com/FelineStateMachine/puzzletea/games/takuzu"24+ "github.com/FelineStateMachine/puzzletea/games/takuzuplus"25+ "github.com/FelineStateMachine/puzzletea/games/wordsearch"1826 "github.com/FelineStateMachine/puzzletea/puzzle"19- "github.com/FelineStateMachine/puzzletea/rippleeffect"20- "github.com/FelineStateMachine/puzzletea/shikaku"21- "github.com/FelineStateMachine/puzzletea/spellpuzzle"22- "github.com/FelineStateMachine/puzzletea/sudoku"23- "github.com/FelineStateMachine/puzzletea/sudokurgb"24- "github.com/FelineStateMachine/puzzletea/takuzu"25- "github.com/FelineStateMachine/puzzletea/takuzuplus"26- "github.com/FelineStateMachine/puzzletea/wordsearch"2727 )2828 2929 type (
M registry/registry_test.go +2 -2
M resolve/resolve_test.go +1 -1
M session/session_test.go +1 -1