clone

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

Merge pull request #48 from FelineStateMachine/feat/menu-changes

FelineStateMachine committed 0f258df
Add Takuzu+, SudokuRBG, new menu, weekly gauntlet, themed help guides, ui/ux modernization pass, testing pass, diagnostic pass
commit
0f258df0de05b21cb7182a2ced194e3a6bc62a1b
parent
13be83c6cdf207ed0809f879492416edcc0e7cfa
parent
a1688aa80314e333a04b1243c93155b76fd7d40a
author
FelineStateMachine <dami.etoile@gmail.com>
210 files changed, +14489 -3331
AAGENTS.md6+
MREADME.md29+-
Aapp/category_item.go37++
Mapp/game_session.go24+-
Mapp/help_layout_test.go52+++
Mapp/keys.go4-
Aapp/markdown_style.go196++++++++++
Aapp/markdown_style_test.go93+++++
Aapp/mode_display.go107+++++
Aapp/mode_display_test.go99+++++
Mapp/model.go68+++-
Aapp/seed_input.go186+++++++++
Mapp/spawn.go17+-
Mapp/update.go308+++++++++++++--
Mapp/update_test.go352+++++++++++++++++-
Mapp/view.go171++++++++-
Aapp/view_test.go22+
Aapp/weekly.go311+++++++++++++++
Aarchitecture_test.go82++++
Mcatalog/catalog.go208+++-------
Mcatalog/catalog_test.go106+----
Mcmd/continue.go4-
Mcmd/export_pdf.go22+-
Mcmd/new.go20+-
Mcmd/new_export.go40+-
Mcmd/root.go3-
Mdaily/daily.go8+-
Mdaily/daily_test.go15+
Mfillomino/Gamemode.go2+-
Mfillomino/Model.go8+-
Mfillomino/PrintAdapter.go3+-
Mfillomino/fillomino_test.go272+++++++++++--
Mfillomino/generator.go6+-
Mfillomino/help.md2+-
Mfillomino/solver.go4+-
Mfillomino/style.go291++++++---------
Mgame/border.go47++-
Mgame/dynamic_grid.go388+++++++++++++++----
Mgame/dynamic_grid_test.go326++++++++++++++++
Mgame/print_adapter.go55+--
Mgame/print_adapter_test.go22+-
Mgame/style.go13+-
Mhashiwokakero/Gamemode.go2+-
Mhashiwokakero/Model.go31+-
Mhashiwokakero/PrintAdapter.go7+-
Mhashiwokakero/README.md6+-
Mhashiwokakero/benchmark_test.go4-
Mhashiwokakero/generation_invariants_test.go4-
Mhashiwokakero/hashiwokakero_test.go347+++++++++++++++++-
Mhashiwokakero/help.md4+-
Mhashiwokakero/keys.go9+-
Ahashiwokakero/mouse.go37++
Mhashiwokakero/style.go470+++++++++++++----------
Mhitori/Gamemode.go2+-
Mhitori/Model.go23+
Mhitori/PrintAdapter.go7+-
Mhitori/README.md2+-
Mhitori/generator.go4+-
Mhitori/help.md2+-
Mhitori/hitori_test.go205++++++++++
Ahitori/mouse.go36++
Mhitori/style.go225+++++++----
Mjustfile7+
Mlightsout/Gamemode.go2+-
Mlightsout/README.md2+-
Mlightsout/generator.go6+-
Mlightsout/help.md2+-
Mlightsout/style.go2+-
Mnonogram/Gamemode.go2+-
Mnonogram/Model.go76++-
Mnonogram/PrintAdapter.go5+-
Mnonogram/README.md2+-
Anonogram/dynamic_grid_test.go264+++++++++++++
Mnonogram/generator.go7+-
Mnonogram/help.md2+-
Mnonogram/mouse.go171+++------
Mnonogram/nonogram_test.go462++++++++++++++++-------
Mnonogram/style.go188++++++----
Mnurikabe/Gamemode.go2+-
Mnurikabe/Model.go33+-
Mnurikabe/PrintAdapter.go3+-
Mnurikabe/README.md7+-
Mnurikabe/generator.go13+-
Mnurikabe/help.md8+-
Mnurikabe/mouse.go58+--
Mnurikabe/nurikabe_test.go602+++++++++++++++++++++++++++++-
Mnurikabe/solver.go32+-
Mnurikabe/style.go301++++++++++++---
Mpdfexport/cover_art.go15+-
Mpdfexport/cover_art_test.go3+-
Mpdfexport/jsonl.go6+-
Mpdfexport/jsonl_test.go89++++-
Mpdfexport/parse.go2+-
Apdfexport/print_adapter.go64+++
Mpdfexport/printdata.go137++++++-
Mpdfexport/printdata_test.go48++
Mpdfexport/render.go9+-
Mpdfexport/render_error_test.go3+-
Mpdfexport/render_title.go4+-
Mpdfexport/types.go8+-
Apuzzle/puzzle.go47++
Aregistry/registry.go218+++++++++++
Mresolve/resolve.go150++++---
Mresolve/resolve_test.go266++++---------
Mrippleeffect/Gamemode.go91++++-
Mrippleeffect/PrintAdapter.go3+-
Mrippleeffect/README.md10+-
Mrippleeffect/generator.go152+++++++-
Mrippleeffect/help.md2+-
Mrippleeffect/rippleeffect_test.go274++++++++++++--
Mrippleeffect/style.go133+++++--
Msession/session.go37++-
Msession/session_test.go15+
Mshikaku/Gamemode.go2+-
Mshikaku/Model.go13+-
Mshikaku/PrintAdapter.go3+-
Mshikaku/README.md9+-
Mshikaku/benchmark_test.go4-
Mshikaku/generation_invariants_test.go6-
Mshikaku/help.md7+-
Mshikaku/keys.go11+-
Mshikaku/puzzle.go25+-
Mshikaku/shikaku_test.go125++++---
Mshikaku/style.go111+-----
Astats/layout.go55+++
Mstats/stats.go353++----------------
Mstats/stats_test.go416++------------------
Mstore/game_record.go18+-
Astore/metadata.go93+++++
Astore/migrate.go142+++++++
Mstore/stats.go137++++++-
Mstore/stats_test.go92+++++
Mstore/store.go136++++++-
Mstore/store_test.go65+++
Msudoku/Model.go25+-
Msudoku/PrintAdapter.go3+-
Msudoku/README.md3+-
Msudoku/benchmark_test.go18+-
Asudoku/dynamic_board_test.go385+++++++++++++++++++
Msudoku/help.md3+-
Asudoku/mouse.go33++
Msudoku/style.go226++++++------
Msudoku/uniqueness_test.go3-
Asudokurgb/Export.go81++++
Asudokurgb/Gamemode.go62+++
Asudokurgb/Model.go175+++++++++
Asudokurgb/PrintAdapter.go113++++++
Asudokurgb/README.md52+++
Asudokurgb/cell.go103+++++
Asudokurgb/dynamic_board_test.go145+++++++
Asudokurgb/generator.go304+++++++++++++++
Asudokurgb/help.md35++
Asudokurgb/keys.go46++
Asudokurgb/mouse.go33++
Asudokurgb/style.go251+++++++++++++
Asudokurgb/sudokurgb_test.go176+++++++++
Mtakuzu/Gamemode.go2+-
Mtakuzu/Model.go75+++-
Mtakuzu/PrintAdapter.go132+++++--
Mtakuzu/README.md3+-
Mtakuzu/generator.go59++-
Mtakuzu/grid.go14+
Mtakuzu/help.md3+-
Atakuzu/mouse.go35++
Mtakuzu/print_adapter_test.go75++++
Mtakuzu/style.go119+++---
Mtakuzu/takuzu_test.go215++++++++---
Atakuzuplus/Export.go107+++++
Atakuzuplus/Gamemode.go73++++
Atakuzuplus/Model.go186+++++++++
Atakuzuplus/PrintAdapter.go34++
Atakuzuplus/README.md54+++
Atakuzuplus/generator.go519++++++++++++++++++++++++++
Atakuzuplus/grid.go134+++++++
Atakuzuplus/help.md25+
Atakuzuplus/keys.go49++
Atakuzuplus/logic.go137+++++++
Atakuzuplus/mouse.go35++
Atakuzuplus/profile.go337+++++++++++++++++
Atakuzuplus/style.go174+++++++++
Atakuzuplus/takuzuplus_test.go534+++++++++++++++++++++++++++
Mui/list.go7+-
Aui/stats_view.go169++++++++
Mui/table.go45++-
Mui/ui_test.go35+-
Mvhs/fillomino.gifbin
Mvhs/hashiwokakero.gifbin
Mvhs/help.gifbin
Mvhs/hitori.gifbin
Mvhs/lightsout.gifbin
Mvhs/menu.gifbin
Mvhs/nonogram.gifbin
Mvhs/nurikabe.gifbin
Mvhs/rippleeffect.gifbin
Mvhs/shikaku.gifbin
Mvhs/stats.gifbin
Mvhs/sudoku.gifbin
Mvhs/takuzu.gifbin
Mvhs/wordsearch.gifbin
Aweekly/weekly.go166++++++++
Aweekly/weekly_test.go146+++++++
Mwordsearch/Gamemode.go2+-
Mwordsearch/Model.go241++++++++++++-
Mwordsearch/PrintAdapter.go3+-
Mwordsearch/README.md6+-
Mwordsearch/generator.go15-
Mwordsearch/help.md6+-
Mwordsearch/keys.go4+-
Mwordsearch/style.go57+++-
Mwordsearch/wordsearch_test.go189+++++++++-
A AGENTS.md +6 -0
M README.md +22 -7
@@ -2,16 +2,17 @@
22 33 A terminal-based puzzle game collection built with [Bubble Tea](https://github.com/charmbracelet/bubbletea).44 5-Eleven puzzle types, multiple difficulty modes, daily challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.5+Twelve puzzle types, multiple difficulty modes, daily and weekly deterministic challenges, XP progression, 365 color themes, and an explicit game catalog for adding new games.66 77 ![PuzzleTea menu](vhs/menu.gif)88 99 ## Features1010 11-- **11 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu11+- **12 puzzle games** -- Fillomino, Nonogram, Nurikabe, Ripple Effect, Sudoku, Shikaku, Word Search, Hashiwokakero, Hitori, Lights Out, Takuzu, Takuzu+1212 - **Daily puzzles** -- A unique puzzle generated each day using deterministic seeding. Same date, same puzzle for everyone. Streak tracking rewards consecutive daily completions.13-- **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP.14-- **Stats dashboard** -- Profile level, daily streak, victory counts, and XP progress bars per category.13+- **Weekly gauntlet** -- Each ISO calendar week has a shared 99-puzzle ladder. The current week unlocks sequentially from `#01` to `#99`; past weeks can be reviewed from completed saves only.14+- **XP and leveling** -- Per-category levels based on victories. Harder modes yield more XP. Daily puzzles grant 2x XP, and weekly puzzles add slot-based bonus XP.15+- **Stats dashboard** -- Profile level, daily streak status, weekly completion progress, victory counts, and XP progress bars per category.1516 - **365 color themes** -- Live-preview theme picker with WCAG-compliant contrast enforcement. Dark and light themes included.1617 - **Mouse support** -- Click and drag in Nonogram, Nurikabe, Shikaku, and Word Search. Lights Out supports click-to-toggle.1718 - **Seeded puzzles** -- Share a seed string to generate identical puzzles across sessions and machines.
@@ -32,6 +33,7 @@ Eleven puzzle types, multiple difficulty modes, daily challenges, XP progression
3233 | **Hitori** | Shade cells to eliminate duplicates | 6 modes from 5x5 to 12x12 |3334 | **Lights Out** | Toggle lights to turn all off | Easy (3x3) to Extreme (9x9) |3435 | **Takuzu** | Fill grid with two symbols | 7 modes from 6x6 to 14x14 |36+| **Takuzu+** | Fill grid with symbols plus `=` and `x` relation clues | 7 modes from 6x6 to 14x14 |3537 3638 ## Install3739
@@ -81,6 +83,18 @@ Launch the interactive menu:
8183 puzzletea8284 ```8385 86+The Play menu includes:87+88+- `Create` for a new puzzle by category and mode89+- `Continue` for saved games90+- `Daily` for the shared deterministic daily puzzle91+- `Weekly` for the current or historical weekly gauntlet92+- `Seeded` for a custom deterministic seed93+94+Weekly gauntlets use the ISO week-year format shown in the menu, for example95+`Week 10-2026 # 7`. The `#` value is the currently active weekly challenge for96+that week. Current-week puzzles unlock one at a time; older weeks are review-only.97+8498 Start a new game directly:8599 86100 ```bash
@@ -157,7 +171,7 @@ puzzletea --continue amber-falcon
157171 158172 ### CLI Aliases159173 160-Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `binairo`/`binary` for Takuzu, `words`/`ws` for Word Search, `rectangles` for Shikaku.174+Several shorthand names are accepted for games: `polyomino`/`regions` for Fillomino, `hashi`/`bridges` for Hashiwokakero, `lights` for Lights Out, `islands`/`sea` for Nurikabe, `ripple` for Ripple Effect, `binairo`/`binary` for Takuzu, `binario+` for Takuzu+, `words`/`ws` for Word Search, `rectangles` for Shikaku.161175 162176 ## Controls163177
@@ -166,8 +180,7 @@ Several shorthand names are accepted for games: `polyomino`/`regions` for Fillom
166180 | Key | Action |167181 |-----|--------|168182 | `Enter` | Select |169-| `Escape` | Go back |170-| `Ctrl+N` | Return to main menu |183+| `Escape` | Return to the menu or go back |171184 | `Ctrl+R` | Reset puzzle |172185 | `Ctrl+H` | Toggle full help |173186 | `Ctrl+E` | Toggle debug overlay |
@@ -185,6 +198,8 @@ Nonogram, Nurikabe, Shikaku, and Word Search support click and drag. Lights Out
185198 186199 Games are automatically saved to `~/.puzzletea/history.db` (SQLite). Navigating away saves progress; quitting with `Ctrl+C` marks the game as abandoned. Completed games are preserved and can be revisited.187200 201+Daily and current-week weekly puzzles keep a single deterministic save per seed/week slot. Completed weekly puzzles from prior weeks reopen in review mode and are not modified when viewed again.202+188203 ## Previews189204 190205 ### Fillomino
A app/category_item.go +37 -0
M app/game_session.go +22 -2
@@ -6,28 +6,48 @@ import (
66 "github.com/FelineStateMachine/puzzletea/game"77 sessionflow "github.com/FelineStateMachine/puzzletea/session"88 "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/weekly"910 1011 tea "charm.land/bubbletea/v2"1112 )1213 14+type gameOpenOptions struct {15+ readOnly bool16+ returnState viewState17+ weeklyInfo *weekly.Info18+}19+1320 // activateGame prepares the game with global UI state and moves to game view.14-func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool) model {21+func (m model) activateGame(g game.Gamer, activeGameID int64, completionSaved bool, options gameOpenOptions) model {1522 g, _ = g.Update(game.HelpToggleMsg{Show: m.help.showFull})1623 g, _ = g.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})1724 1825 m.session.game = g1926 m.session.activeGameID = activeGameID2027 m.session.completionSaved = completionSaved28+ m.session.returnState = options.returnState29+ m.session.weeklyAdvance = options.weeklyInfo2130 m.state = gameView2231 return m2332 }2433 2534 func (m model) importAndActivateRecord(rec store.GameRecord) (model, bool) {35+ return m.importAndActivateRecordWithOptions(rec, gameOpenOptions{returnState: mainMenuView})36+}37+38+func (m model) importAndActivateRecordWithOptions(rec store.GameRecord, options gameOpenOptions) (model, bool) {2639 g, err := sessionflow.ImportRecord(&rec)2740 if err != nil {2841 log.Printf("failed to import game: %v", err)2942 return m, false3043 }3144 32- return m.activateGame(g, rec.ID, rec.Status == store.StatusCompleted), true45+ activeGameID := rec.ID46+ completionSaved := rec.Status == store.StatusCompleted47+ if options.readOnly {48+ activeGameID = 049+ completionSaved = true50+ }51+52+ return m.activateGame(g, activeGameID, completionSaved, options), true3353 }
M app/help_layout_test.go +52 -0
@@ -39,6 +39,30 @@ func TestHelpViewportSizeNeverFallsBelowOne(t *testing.T) {
3939 }4040 }4141 42+func TestHelpSelectListSizeKeepsPanelInsideInsetBounds(t *testing.T) {43+ const (44+ windowWidth = 12045+ windowHeight = 2046+ )47+48+ helpList := ui.InitList(gameCategoryItems, "How to Play")49+ listWidth, listHeight := helpSelectListSize(windowWidth, windowHeight, helpList)50+ helpList.SetSize(listWidth, listHeight)51+52+ panel := ui.Panel(53+ "How to Play",54+ helpList.View(),55+ "↑/↓ navigate • enter select • esc back",56+ )57+58+ if got := lipgloss.Width(panel); got > windowWidth-(helpPanelInsetX*2) {59+ t.Fatalf("panel width = %d, want <= %d", got, windowWidth-(helpPanelInsetX*2))60+ }61+ if got := lipgloss.Height(panel); got > windowHeight-(helpPanelInsetY*2) {62+ t.Fatalf("panel height = %d, want <= %d", got, windowHeight-(helpPanelInsetY*2))63+ }64+}65+4266 func TestStatsViewportSizeMatchesHelpWidth(t *testing.T) {4367 helpWidth, _ := helpViewportSize(120, 40)4468 statsWidth, _ := statsViewportSize(120, 40, nil)
@@ -55,3 +79,31 @@ func TestStatsViewportSizeReservesBannerHeight(t *testing.T) {
5579 t.Fatalf("stats height = %d, want %d", statsHeight, helpHeight-5)5680 }5781 }82+83+func TestGameSelectViewContentSizeIsStableAcrossCategories(t *testing.T) {84+ l := ui.InitCategoryList(GameCategories, "Select Category")85+ m := model{86+ state: gameSelectView,87+ width: 120,88+ height: 40,89+ nav: navigationState{90+ gameSelectList: l,91+ },92+ }93+ m = m.updateCategoryDetailViewport()94+95+ wantWidth := lipgloss.Width(m.gameSelectViewContent())96+ wantHeight := lipgloss.Height(m.gameSelectViewContent())97+98+ for i := range m.nav.gameSelectList.Items() {99+ m.nav.gameSelectList.Select(i)100+ m = m.updateCategoryDetailViewport()101+102+ if got := lipgloss.Width(m.gameSelectViewContent()); got != wantWidth {103+ t.Fatalf("selection %d width = %d, want %d", i, got, wantWidth)104+ }105+ if got := lipgloss.Height(m.gameSelectViewContent()); got != wantHeight {106+ t.Fatalf("selection %d height = %d, want %d", i, got, wantHeight)107+ }108+ }109+}
M app/keys.go +0 -4
A app/markdown_style.go +196 -0
@@ -0,0 +1,196 @@
1+package app2+3+import (4+ "fmt"5+ "image/color"6+7+ "github.com/FelineStateMachine/puzzletea/theme"8+ "github.com/charmbracelet/glamour/ansi"9+)10+11+func helpMarkdownStyle(p theme.Palette) ansi.StyleConfig {12+ return ansi.StyleConfig{13+ Document: ansi.StyleBlock{14+ StylePrimitive: ansi.StylePrimitive{15+ Color: colorPtr(p.FG),16+ },17+ },18+ BlockQuote: ansi.StyleBlock{19+ StylePrimitive: ansi.StylePrimitive{20+ Color: colorPtr(p.TextDim),21+ },22+ Indent: uintPtr(1),23+ IndentToken: stringPtr("│ "),24+ Margin: uintPtr(1),25+ },26+ Paragraph: ansi.StyleBlock{27+ StylePrimitive: ansi.StylePrimitive{28+ Color: colorPtr(p.FG),29+ },30+ Margin: uintPtr(1),31+ },32+ List: ansi.StyleList{33+ StyleBlock: ansi.StyleBlock{34+ StylePrimitive: ansi.StylePrimitive{35+ Color: colorPtr(p.FG),36+ },37+ Margin: uintPtr(1),38+ },39+ LevelIndent: 2,40+ },41+ Heading: ansi.StyleBlock{42+ StylePrimitive: ansi.StylePrimitive{43+ Color: colorPtr(p.Accent),44+ Bold: boolPtr(true),45+ },46+ Margin: uintPtr(1),47+ },48+ H1: ansi.StyleBlock{49+ StylePrimitive: ansi.StylePrimitive{50+ Color: colorPtr(p.Accent),51+ Bold: boolPtr(true),52+ },53+ Margin: uintPtr(1),54+ },55+ H2: ansi.StyleBlock{56+ StylePrimitive: ansi.StylePrimitive{57+ Color: colorPtr(p.AccentSoft),58+ Bold: boolPtr(true),59+ },60+ Margin: uintPtr(1),61+ },62+ H3: ansi.StyleBlock{63+ StylePrimitive: ansi.StylePrimitive{64+ Color: colorPtr(p.Info),65+ Bold: boolPtr(true),66+ },67+ Margin: uintPtr(1),68+ },69+ H4: ansi.StyleBlock{70+ StylePrimitive: ansi.StylePrimitive{71+ Color: colorPtr(p.Secondary),72+ Bold: boolPtr(true),73+ },74+ },75+ H5: ansi.StyleBlock{76+ StylePrimitive: ansi.StylePrimitive{77+ Color: colorPtr(p.Linked),78+ Bold: boolPtr(true),79+ },80+ },81+ H6: ansi.StyleBlock{82+ StylePrimitive: ansi.StylePrimitive{83+ Color: colorPtr(p.TextDim),84+ Bold: boolPtr(true),85+ },86+ },87+ Text: ansi.StylePrimitive{88+ Color: colorPtr(p.FG),89+ },90+ Emph: ansi.StylePrimitive{91+ Color: colorPtr(p.Secondary),92+ Italic: boolPtr(true),93+ },94+ Strong: ansi.StylePrimitive{95+ Color: colorPtr(p.AccentSoft),96+ Bold: boolPtr(true),97+ },98+ HorizontalRule: ansi.StylePrimitive{99+ Color: colorPtr(p.Border),100+ },101+ Item: ansi.StylePrimitive{102+ Color: colorPtr(p.Accent),103+ },104+ Enumeration: ansi.StylePrimitive{105+ Color: colorPtr(p.Accent),106+ },107+ Task: ansi.StyleTask{108+ StylePrimitive: ansi.StylePrimitive{109+ Color: colorPtr(p.Accent),110+ },111+ Ticked: "[x]",112+ Unticked: "[ ]",113+ },114+ Link: ansi.StylePrimitive{115+ Color: colorPtr(p.Highlight),116+ Underline: boolPtr(true),117+ },118+ LinkText: ansi.StylePrimitive{119+ Color: colorPtr(p.Highlight),120+ Underline: boolPtr(true),121+ },122+ Code: ansi.StyleBlock{123+ StylePrimitive: ansi.StylePrimitive{124+ Color: colorPtr(p.AccentSoft),125+ BackgroundColor: colorPtr(p.Surface),126+ },127+ },128+ CodeBlock: ansi.StyleCodeBlock{129+ StyleBlock: ansi.StyleBlock{130+ StylePrimitive: ansi.StylePrimitive{131+ Color: colorPtr(p.FG),132+ BackgroundColor: colorPtr(p.Surface),133+ },134+ Margin: uintPtr(1),135+ },136+ },137+ Table: ansi.StyleTable{138+ StyleBlock: ansi.StyleBlock{139+ StylePrimitive: ansi.StylePrimitive{140+ Color: colorPtr(p.FG),141+ },142+ Margin: uintPtr(1),143+ },144+ CenterSeparator: stringPtr("┼"),145+ ColumnSeparator: stringPtr("│"),146+ RowSeparator: stringPtr("─"),147+ },148+ DefinitionTerm: ansi.StylePrimitive{149+ Color: colorPtr(p.Accent),150+ Bold: boolPtr(true),151+ },152+ DefinitionDescription: ansi.StylePrimitive{153+ Color: colorPtr(p.FG),154+ },155+ Strikethrough: ansi.StylePrimitive{156+ Color: colorPtr(p.TextDim),157+ },158+ }159+}160+161+func helpMarkdownThemeKey(p theme.Palette) string {162+ return fmt.Sprintf(163+ "%s:%s:%s:%s:%s:%s:%s:%s:%s",164+ hexColor(p.BG),165+ hexColor(p.FG),166+ hexColor(p.Surface),167+ hexColor(p.Accent),168+ hexColor(p.AccentSoft),169+ hexColor(p.Info),170+ hexColor(p.Highlight),171+ hexColor(p.Border),172+ hexColor(p.TextDim),173+ )174+}175+176+func hexColor(c color.Color) string {177+ r, g, b, _ := c.RGBA()178+ return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))179+}180+181+func colorPtr(c color.Color) *string {182+ s := hexColor(c)183+ return &s184+}185+186+func boolPtr(v bool) *bool {187+ return &v188+}189+190+func stringPtr(v string) *string {191+ return &v192+}193+194+func uintPtr(v uint) *uint {195+ return &v196+}
A app/markdown_style_test.go +93 -0
@@ -0,0 +1,93 @@
1+package app2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/puzzle"7+ "github.com/FelineStateMachine/puzzletea/registry"8+ "github.com/FelineStateMachine/puzzletea/theme"9+)10+11+func TestHelpMarkdownStyleUsesPaletteColors(t *testing.T) {12+ if err := theme.Apply(""); err != nil {13+ t.Fatalf("apply default theme: %v", err)14+ }15+ t.Cleanup(func() { _ = theme.Apply("") })16+17+ p := theme.Current()18+ style := helpMarkdownStyle(p)19+20+ if got := *style.H1.Color; got != hexColor(p.Accent) {21+ t.Fatalf("H1 color = %q, want %q", got, hexColor(p.Accent))22+ }23+ if got := *style.Code.StylePrimitive.BackgroundColor; got != hexColor(p.Surface) {24+ t.Fatalf("inline code background = %q, want %q", got, hexColor(p.Surface))25+ }26+ if got := *style.Link.Color; got != hexColor(p.Highlight) {27+ t.Fatalf("link color = %q, want %q", got, hexColor(p.Highlight))28+ }29+}30+31+func TestHelpMarkdownThemeKeyChangesWithTheme(t *testing.T) {32+ if err := theme.Apply(""); err != nil {33+ t.Fatalf("apply default theme: %v", err)34+ }35+ t.Cleanup(func() { _ = theme.Apply("") })36+37+ defaultKey := helpMarkdownThemeKey(theme.Current())38+39+ if err := theme.Apply("Dracula"); err != nil {40+ t.Fatalf("apply Dracula theme: %v", err)41+ }42+43+ draculaKey := helpMarkdownThemeKey(theme.Current())44+ if draculaKey == defaultKey {45+ t.Fatalf("theme key did not change across themes: %q", draculaKey)46+ }47+}48+49+func TestUpdateHelpDetailViewportRebuildsRendererAfterThemeChange(t *testing.T) {50+ if err := theme.Apply(""); err != nil {51+ t.Fatalf("apply default theme: %v", err)52+ }53+ t.Cleanup(func() { _ = theme.Apply("") })54+55+ m := model{56+ width: 100,57+ height: 30,58+ help: helpState{59+ category: registry.Entry{60+ Definition: puzzle.Definition{Name: "Nonogram"},61+ Help: "# Nonogram\n\n- clue\n",62+ },63+ },64+ }65+66+ m = m.updateHelpDetailViewport()67+ if m.help.renderer == nil {68+ t.Fatal("expected initial help renderer")69+ }70+71+ initialRenderer := m.help.renderer72+ initialThemeKey := m.help.rendererTheme73+74+ m = m.updateHelpDetailViewport()75+ if m.help.renderer != initialRenderer {76+ t.Fatal("expected help renderer to be reused when width and theme are unchanged")77+ }78+79+ if err := theme.Apply("Dracula"); err != nil {80+ t.Fatalf("apply Dracula theme: %v", err)81+ }82+83+ m = m.updateHelpDetailViewport()84+ if m.help.renderer == nil {85+ t.Fatal("expected help renderer after theme change")86+ }87+ if m.help.renderer == initialRenderer {88+ t.Fatal("expected help renderer to rebuild after theme change")89+ }90+ if m.help.rendererTheme == initialThemeKey {91+ t.Fatalf("renderer theme key = %q, want change from %q", m.help.rendererTheme, initialThemeKey)92+ }93+}
A app/mode_display.go +107 -0
@@ -0,0 +1,107 @@
1+package app2+3+import (4+ "strings"5+6+ "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+ "github.com/FelineStateMachine/puzzletea/registry"9+)10+11+type modeDisplayItem struct {12+ mode registry.ModeEntry13+ title string14+ description string15+ filterValue string16+}17+18+func (i modeDisplayItem) Title() string { return i.title }19+func (i modeDisplayItem) Description() string { return i.description }20+func (i modeDisplayItem) FilterValue() string { return i.filterValue }21+func (i modeDisplayItem) Original() registry.ModeEntry { return i.mode }22+23+func buildModeDisplayItems(entry registry.Entry) []list.Item {24+ titles := modeDisplayTitles(entry)25+ items := make([]list.Item, 0, len(entry.Modes))26+27+ for idx, mode := range entry.Modes {28+ displayTitle := titles[idx]29+ filterValue := displayTitle + " " + mode.Definition.Description30+ if displayTitle != mode.Definition.Title {31+ filterValue += " " + mode.Definition.Title32+ }33+34+ items = append(items, modeDisplayItem{35+ mode: mode,36+ title: displayTitle,37+ description: mode.Definition.Description,38+ filterValue: strings.TrimSpace(filterValue),39+ })40+ }41+42+ return items43+}44+45+func modeDisplayTitles(entry registry.Entry) []string {46+ counts := make(map[string]int, len(entry.Modes))47+ titles := make([]string, 0, len(entry.Modes))48+49+ for _, mode := range entry.Modes {50+ base, _ := trimGridSizeSuffix(mode.Definition.Title)51+ counts[puzzle.NormalizeName(base)]++52+ }53+54+ for _, mode := range entry.Modes {55+ title := mode.Definition.Title56+ base, hadSize := trimGridSizeSuffix(title)57+ if hadSize && counts[puzzle.NormalizeName(base)] == 1 {58+ title = base59+ }60+ titles = append(titles, title)61+ }62+63+ return titles64+}65+66+func unwrapModeDisplayItem(item list.Item) any {67+ displayItem, ok := item.(interface{ Original() registry.ModeEntry })68+ if !ok {69+ return item70+ }71+ return displayItem.Original()72+}73+74+func trimGridSizeSuffix(title string) (string, bool) {75+ fields := strings.Fields(title)76+ if len(fields) < 2 {77+ return title, false78+ }79+80+ if !isGridSizeToken(fields[len(fields)-1]) {81+ return title, false82+ }83+84+ base := strings.TrimSpace(strings.Join(fields[:len(fields)-1], " "))85+ if base == "" {86+ return title, false87+ }88+89+ return base, true90+}91+92+func isGridSizeToken(token string) bool {93+ width, height, found := strings.Cut(token, "x")94+ if !found || width == "" || height == "" {95+ return false96+ }97+ return digitsOnly(width) && digitsOnly(height)98+}99+100+func digitsOnly(s string) bool {101+ for _, r := range s {102+ if r < '0' || r > '9' {103+ return false104+ }105+ }106+ return true107+}
A app/mode_display_test.go +99 -0
@@ -0,0 +1,99 @@
1+package app2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/puzzle"8+ "github.com/FelineStateMachine/puzzletea/registry"9+)10+11+type testMode struct {12+ game.BaseMode13+}14+15+func newTestMode(title string) testMode {16+ return testMode{BaseMode: game.NewBaseMode(title, title+" description")}17+}18+19+func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {20+ t.Parallel()21+22+ entry := registry.Entry{23+ Definition: puzzle.Definition{Name: "Example"},24+ Modes: []registry.ModeEntry{25+ {Definition: puzzle.ModeDef{Title: "Mini 5x5", Description: "Mini 5x5 description"}},26+ {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},27+ },28+ }29+30+ got := modeDisplayTitles(entry)31+ want := []string{"Mini", "Hard"}32+33+ if len(got) != len(want) {34+ t.Fatalf("len(modeDisplayTitles()) = %d, want %d", len(got), len(want))35+ }36+ for i := range want {37+ if got[i] != want[i] {38+ t.Fatalf("modeDisplayTitles()[%d] = %q, want %q", i, got[i], want[i])39+ }40+ }41+}42+43+func TestModeDisplayTitlesKeepDuplicateBaseNames(t *testing.T) {44+ t.Parallel()45+46+ entry := registry.Entry{47+ Definition: puzzle.Definition{Name: "Example"},48+ Modes: []registry.ModeEntry{49+ {Definition: puzzle.ModeDef{Title: "Easy 5x5", Description: "Easy 5x5 description"}},50+ {Definition: puzzle.ModeDef{Title: "Easy 10x10", Description: "Easy 10x10 description"}},51+ {Definition: puzzle.ModeDef{Title: "Hard 10x10", Description: "Hard 10x10 description"}},52+ },53+ }54+55+ got := modeDisplayTitles(entry)56+ want := []string{"Easy 5x5", "Easy 10x10", "Hard"}57+58+ if len(got) != len(want) {59+ t.Fatalf("len(modeDisplayTitles()) = %d, want %d", len(got), len(want))60+ }61+ for i := range want {62+ if got[i] != want[i] {63+ t.Fatalf("modeDisplayTitles()[%d] = %q, want %q", i, got[i], want[i])64+ }65+ }66+}67+68+func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {69+ t.Parallel()70+71+ original := newTestMode("Hard 10x10")72+ entry := registry.Entry{73+ Definition: puzzle.Definition{Name: "Example"},74+ Modes: []registry.ModeEntry{{75+ Definition: puzzle.ModeDef{Title: original.Title(), Description: original.Description()},76+ }},77+ }78+79+ items := buildModeDisplayItems(entry)80+ if len(items) != 1 {81+ t.Fatalf("len(buildModeDisplayItems()) = %d, want 1", len(items))82+ }83+84+ displayMode, ok := items[0].(game.Mode)85+ if !ok {86+ t.Fatal("buildModeDisplayItems()[0] does not implement game.Mode")87+ }88+ if got := displayMode.Title(); got != "Hard" {89+ t.Fatalf("display item Title() = %q, want %q", got, "Hard")90+ }91+92+ mode, ok := unwrapModeDisplayItem(items[0]).(registry.ModeEntry)93+ if !ok {94+ t.Fatal("unwrapModeDisplayItem() did not return a registry.ModeEntry")95+ }96+ if got := mode.Definition.Title; got != "Hard 10x10" {97+ t.Fatalf("unwrapped mode Title() = %q, want %q", got, "Hard 10x10")98+ }99+}
M app/model.go +51 -17
@@ -4,15 +4,17 @@ package app
44 55 import (66 "context"7+ "strconv"78 "time"89 9- "github.com/FelineStateMachine/puzzletea/catalog"1010 "github.com/FelineStateMachine/puzzletea/config"1111 "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/registry"1213 "github.com/FelineStateMachine/puzzletea/stats"1314 "github.com/FelineStateMachine/puzzletea/store"1415 "github.com/FelineStateMachine/puzzletea/theme"1516 "github.com/FelineStateMachine/puzzletea/ui"17+ "github.com/FelineStateMachine/puzzletea/weekly"1618 1719 "charm.land/bubbles/v2/list"1820 "charm.land/bubbles/v2/spinner"
@@ -24,11 +26,10 @@ import (
2426 "github.com/charmbracelet/glamour"2527 )2628 27-// Categories is the typed catalog view used by stats and print-export helpers.28-var Categories = catalog.Categories()29-30-// GameCategories is the list.Item view of Categories for Bubble Tea lists.31-var GameCategories = catalog.CategoryItems()29+var (30+ gameCategoryItems = buildCategoryItems()31+ GameCategories = gameCategoryItems32+)3233 3334 type viewState int3435
@@ -39,13 +40,6 @@ var mainMenuItems = []ui.MenuItem{
3940 {ItemTitle: "Quit", Desc: "exit puzzletea"},4041 }4142 42-var playMenuItems = []ui.MenuItem{43- {ItemTitle: "Daily Puzzle", Desc: time.Now().Format("Jan _2 06")},44- {ItemTitle: "Generate", Desc: "a new puzzle"},45- {ItemTitle: "Continue", Desc: "a previously played puzzle"},46- {ItemTitle: "Enter Seed", Desc: "for a set puzzle"},47-}48-4943 var optionsMenuItems = []ui.MenuItem{5044 {ItemTitle: "Theme", Desc: "change colors"},5145 {ItemTitle: "Guides", Desc: "learn the rules"},
@@ -61,6 +55,7 @@ const (
6155 generatingView6256 gameView6357 continueView58+ weeklyView6459 helpSelectView6560 helpDetailView6661 statsView
@@ -72,12 +67,20 @@ type navigationState struct {
7267 playMenu ui.MainMenu7368 optionsMenu ui.MainMenu7469 gameSelectList list.Model70+ categoryDetail viewport.Model7571 modeSelectList list.Model76- selectedCategory game.Category72+ selectedCategory registry.Entry7773 selectedModeTitle string7874 continueTable table.Model7975 continueGames []store.GameRecord76+ weeklyTable table.Model77+ weeklyRows []weeklyRow78+ weeklyCursor time.Time8079 seedInput textinput.Model80+ seedModeOptions []seedModeOption81+ seedModeIndex int82+ seedFocus seedInputFocus83+ lastSeedModeKey string8184 helpSelectList list.Model8285 }8386
@@ -85,6 +88,8 @@ type sessionState struct {
8588 game game.Gamer8689 activeGameID int648790 completionSaved bool91+ returnState viewState92+ weeklyAdvance *weekly.Info8893 generating bool8994 spawnJobID int649095 spawnCancel context.CancelFunc
@@ -92,10 +97,11 @@ type sessionState struct {
9297 }9398 9499 type helpState struct {95- category game.Category100+ category registry.Entry96101 viewport viewport.Model97102 renderer *glamour.TermRenderer98103 rendererWidth int104+ rendererTheme string99105 showFull bool100106 }101107
@@ -122,6 +128,7 @@ const (
122128 spawnSourceNormal spawnSource = "normal"123129 spawnSourceDaily spawnSource = "daily"124130 spawnSourceSeed spawnSource = "seed"131+ spawnSourceWeekly spawnSource = "weekly"125132 )126133 127134 type spawnRequest struct {
@@ -130,6 +137,21 @@ type spawnRequest struct {
130137 gameType string131138 modeTitle string132139 returnState viewState140+ exitState viewState141+ weeklyInfo *weekly.Info142+}143+144+type seedInputFocus int145+146+const (147+ seedFocusText seedInputFocus = iota148+ seedFocusMode149+)150+151+type seedModeOption struct {152+ key string153+ label string154+ gameType string133155 }134156 135157 type model struct {
@@ -164,7 +186,7 @@ func newSpinner() spinner.Model {
164186 // InitialModel creates the root TUI model for the main menu.165187 func InitialModel(s *store.Store, cfg *config.Config) model {166188 r := initDebugRenderer()167- l := ui.InitCategoryList(GameCategories, "Select Category")189+ l := ui.InitCategoryList(gameCategoryItems, "Select Category")168190 mm := ui.NewMainMenu(mainMenuItems)169191 return model{170192 state: mainMenuView,
@@ -180,7 +202,7 @@ func InitialModel(s *store.Store, cfg *config.Config) model {
180202 // bypassing the menu. Used by CLI flags (--new, --continue).181203 func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, activeGameID int64, completionSaved bool) model {182204 r := initDebugRenderer()183- l := ui.InitCategoryList(GameCategories, "Select Category")205+ l := ui.InitCategoryList(gameCategoryItems, "Select Category")184206 mm := ui.NewMainMenu(mainMenuItems)185207 return model{186208 state: gameView,
@@ -193,6 +215,7 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
193215 game: g,194216 activeGameID: activeGameID,195217 completionSaved: completionSaved,218+ returnState: mainMenuView,196219 },197220 }198221 }
@@ -200,3 +223,14 @@ func InitialModelWithGame(s *store.Store, cfg *config.Config, g game.Gamer, acti
200223 func (m model) Init() tea.Cmd {201224 return nil202225 }226+227+func buildPlayMenuItems(now time.Time, currentWeeklyIndex int) []ui.MenuItem {228+ year, week := now.ISOWeek()229+ return []ui.MenuItem{230+ {ItemTitle: "Create", Desc: "a new puzzle"},231+ {ItemTitle: "Continue", Desc: "a previously played puzzle"},232+ {ItemTitle: "Daily", Desc: now.Format("Jan _2 06")},233+ {ItemTitle: "Weekly", Desc: "Week " + formatTwoDigits(week) + "-" + strconv.Itoa(year) + " #" + formatWeeklyMenuIndex(currentWeeklyIndex)},234+ {ItemTitle: "Seeded", Desc: "enter a specific seed"},235+ }236+}
A app/seed_input.go +186 -0
@@ -0,0 +1,186 @@
1+package app2+3+import (4+ "strings"5+6+ "github.com/FelineStateMachine/puzzletea/puzzle"7+ "github.com/FelineStateMachine/puzzletea/registry"8+ "github.com/FelineStateMachine/puzzletea/theme"9+ "github.com/FelineStateMachine/puzzletea/ui"10+11+ "charm.land/bubbles/v2/textinput"12+ tea "charm.land/bubbletea/v2"13+ "charm.land/lipgloss/v2"14+)15+16+const randomSeedModeLabel = "Random"17+18+func buildSeedModeOptions(definitions []puzzle.Definition) []seedModeOption {19+ options := []seedModeOption{{label: randomSeedModeLabel}}20+21+ for _, def := range definitions {22+ if !definitionHasSeededModes(def) {23+ continue24+ }25+26+ for _, mode := range def.Modes {27+ if !mode.Seeded {28+ continue29+ }30+31+ options = append(options, seedModeOption{32+ key: seedModeKey(def.Name, ""),33+ label: def.Name,34+ gameType: def.Name,35+ })36+ break37+ }38+ }39+40+ return options41+}42+43+func seedModeKey(gameType, modeTitle string) string {44+ if modeTitle == "" {45+ return puzzle.NormalizeName(gameType)46+ }47+ return puzzle.NormalizeName(gameType) + "::" + puzzle.NormalizeName(modeTitle)48+}49+50+func definitionHasSeededModes(def puzzle.Definition) bool {51+ for _, mode := range def.Modes {52+ if mode.Seeded {53+ return true54+ }55+ }56+ return false57+}58+59+func findSeedModeIndex(options []seedModeOption, key string) int {60+ if key == "" {61+ return 062+ }63+ for i := range options {64+ if options[i].key == key {65+ return i66+ }67+ }68+ return 069+}70+71+func (m model) enterSeedInputView() (model, tea.Cmd) {72+ ti := textinput.New()73+ ti.Placeholder = "any word or phrase"74+ ti.CharLimit = 6475+ ti.SetWidth(min(m.width, 48))76+77+ options := buildSeedModeOptions(registry.Definitions())78+ index := findSeedModeIndex(options, m.nav.lastSeedModeKey)79+80+ m.nav.seedInput = ti81+ m.nav.seedModeOptions = options82+ m.nav.seedModeIndex = index83+ m.nav.seedFocus = seedFocusText84+ m.state = seedInputView85+ return m, m.nav.seedInput.Focus()86+}87+88+func (m model) currentSeedMode() seedModeOption {89+ if len(m.nav.seedModeOptions) == 0 {90+ return seedModeOption{label: randomSeedModeLabel}91+ }92+ if m.nav.seedModeIndex < 0 || m.nav.seedModeIndex >= len(m.nav.seedModeOptions) {93+ return m.nav.seedModeOptions[0]94+ }95+ return m.nav.seedModeOptions[m.nav.seedModeIndex]96+}97+98+func (m model) moveSeedMode(step int) model {99+ if len(m.nav.seedModeOptions) == 0 {100+ return m101+ }102+103+ index := m.nav.seedModeIndex + step104+ for index < 0 {105+ index += len(m.nav.seedModeOptions)106+ }107+ m.nav.seedModeIndex = index % len(m.nav.seedModeOptions)108+ m.nav.lastSeedModeKey = m.currentSeedMode().key109+ return m110+}111+112+func (m model) setSeedFocus(focus seedInputFocus) (model, tea.Cmd) {113+ m.nav.seedFocus = focus114+ if focus == seedFocusText {115+ return m, m.nav.seedInput.Focus()116+ }117+ m.nav.seedInput.Blur()118+ return m, nil119+}120+121+func (m model) handleSeedInputUpdate(msg tea.Msg) (model, tea.Cmd) {122+ keyMsg, ok := msg.(tea.KeyPressMsg)123+ if ok {124+ switch keyMsg.String() {125+ case "up", "k":126+ return m.setSeedFocus(seedFocusText)127+ case "down", "j":128+ return m.setSeedFocus(seedFocusMode)129+ case "left", "h":130+ if m.nav.seedFocus == seedFocusMode {131+ return m.moveSeedMode(-1), nil132+ }133+ case "right", "l":134+ if m.nav.seedFocus == seedFocusMode {135+ return m.moveSeedMode(1), nil136+ }137+ }138+ }139+140+ if m.nav.seedFocus != seedFocusText {141+ return m, nil142+ }143+144+ var cmd tea.Cmd145+ m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)146+ return m, cmd147+}148+149+func (m model) seedInputBody() string {150+ selector := renderSeedModeTitle(m.currentSeedMode().label, m.nav.seedModeIndex)151+ modePrefix := " "152+ if m.nav.seedFocus == seedFocusMode {153+ modePrefix = ui.CursorStyle().Render("> ")154+ }155+156+ seedInputView := m.nav.seedInput.View()157+ if m.nav.seedFocus != seedFocusText {158+ seedInputView = strings.Replace(seedInputView, ">", " ", 1)159+ }160+161+ return lipgloss.JoinVertical(162+ lipgloss.Left,163+ seedInputView,164+ "",165+ modePrefix+selector,166+ )167+}168+169+func renderSeedModeTitle(label string, colorIndex int) string {170+ colors := theme.Current().ThemeColors()171+ if len(colors) == 0 {172+ return ui.PanelTitle().Render("game: " + label)173+ }174+175+ bg := colors[((colorIndex%len(colors))+len(colors))%len(colors)]176+ chip := lipgloss.NewStyle().177+ Bold(true).178+ Foreground(theme.TextOnBG(bg)).179+ Background(bg).180+ Render(label)181+182+ var b strings.Builder183+ b.WriteString(ui.PanelTitle().Render("game: "))184+ b.WriteString(chip)185+ return b.String()186+}
M app/spawn.go +15 -2
@@ -113,7 +113,10 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
113113 log.Printf("missing spawn request metadata")114114 return m, nil115115 }116- m = m.activateGame(msg.Game.SetTitle(request.name), 0, false)116+ m = m.activateGame(msg.Game.SetTitle(request.name), 0, false, gameOpenOptions{117+ returnState: request.exitState,118+ weeklyInfo: request.weeklyInfo,119+ })117120 118121 // Capture initial state and create DB record.119122 rec, err := sessionflow.CreateRecord(
@@ -133,9 +136,12 @@ func (m model) handleSpawnComplete(jobID int64, msg game.SpawnCompleteMsg) (tea.
133136 134137 // saveCurrentGame saves the current game state to the DB if a game is active.135138 func saveCurrentGame(m model, status store.GameStatus) model {136- if m.session.game == nil || m.session.activeGameID == 0 {139+ if m.session.game == nil {137140 return m138141 }142+ if m.session.activeGameID == 0 {143+ return clearActiveGame(m)144+ }139145 saveData, err := m.session.game.GetSave()140146 if err != nil {141147 log.Printf("failed to get save data: %v", err)
@@ -150,7 +156,14 @@ func saveCurrentGame(m model, status store.GameStatus) model {
150156 log.Printf("failed to update game status: %v", err)151157 }152158 }159+ return clearActiveGame(m)160+}161+162+func clearActiveGame(m model) model {153163 m.session.activeGameID = 0154164 m.session.game = nil165+ m.session.completionSaved = false166+ m.session.returnState = mainMenuView167+ m.session.weeklyAdvance = nil155168 return m156169 }
M app/update.go +261 -47
@@ -4,10 +4,10 @@ import (
44 "log"55 "time"66 7- "github.com/FelineStateMachine/puzzletea/catalog"87 "github.com/FelineStateMachine/puzzletea/config"98 "github.com/FelineStateMachine/puzzletea/daily"109 "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/registry"1111 "github.com/FelineStateMachine/puzzletea/resolve"1212 sessionflow "github.com/FelineStateMachine/puzzletea/session"1313 "github.com/FelineStateMachine/puzzletea/stats"
@@ -17,7 +17,6 @@ import (
1717 1818 "charm.land/bubbles/v2/key"1919 "charm.land/bubbles/v2/list"20- "charm.land/bubbles/v2/textinput"2120 "charm.land/bubbles/v2/viewport"2221 tea "charm.land/bubbletea/v2"2322 "github.com/charmbracelet/glamour"
@@ -28,6 +27,16 @@ const (
2827 helpPanelInsetY = 12928 helpPanelHorizontalTrim = 63029 helpPanelVerticalTrim = 830+ categoryPanelChrome = 831+ categoryBodyMaxWidth = 8632+ categoryBodyMaxHeight = 1633+ categoryMinListWidth = 2434+ categoryMaxListWidth = 3035+ categoryGapWidth = 236+ categoryDetailTrimX = 637+ categoryDetailTrimY = 438+ categoryStackGapHeight = 139+ categoryMinSideBySideW = 723140 )3241 3342 func helpViewportSize(width, height int) (int, int) {
@@ -38,6 +47,13 @@ func helpViewportSize(width, height int) (int, int) {
3847 return contentWidth, contentHeight3948 }4049 50+func helpSelectListSize(width, height int, l list.Model) (int, int) {51+ contentWidth, contentHeight := helpViewportSize(width, height)52+ listWidth := min(contentWidth, 64)53+ listHeight := min(contentHeight, ui.ListHeight(l))54+ return listWidth, listHeight55+}56+4157 func statsViewportSize(width, height int, cards []stats.Card) (int, int) {4258 contentWidth, _ := helpViewportSize(width, height)4359 panelHeight := max(height-(helpPanelInsetY*2), 1)
@@ -45,20 +61,113 @@ func statsViewportSize(width, height int, cards []stats.Card) (int, int) {
4561 return contentWidth, contentHeight4662 }4763 64+type categoryPickerMetrics struct {65+ bodyWidth int66+ bodyHeight int67+ listWidth int68+ listHeight int69+ detailWidth int70+ detailHeight int71+ stacked bool72+}73+74+func categoryPickerSize(width, height int) categoryPickerMetrics {75+ bodyWidth := min(width, categoryBodyMaxWidth)76+ bodyHeight := min(max(height-categoryPanelChrome, 1), categoryBodyMaxHeight)77+78+ if bodyWidth < categoryMinSideBySideW {79+ listHeight := min(bodyHeight, categoryPickerListHeight())80+ detailHeight := max(bodyHeight-listHeight-categoryStackGapHeight, 1)81+ if detailHeight == 1 && bodyHeight > 1 {82+ listHeight = max(bodyHeight-categoryStackGapHeight-detailHeight, 1)83+ }84+ return categoryPickerMetrics{85+ bodyWidth: bodyWidth,86+ bodyHeight: bodyHeight,87+ listWidth: bodyWidth,88+ listHeight: listHeight,89+ detailWidth: bodyWidth,90+ detailHeight: detailHeight,91+ stacked: true,92+ }93+ }94+95+ listWidth := min(categoryMaxListWidth, max(categoryMinListWidth, bodyWidth/3))96+ detailWidth := max(bodyWidth-listWidth-categoryGapWidth, 1)97+ return categoryPickerMetrics{98+ bodyWidth: bodyWidth,99+ bodyHeight: bodyHeight,100+ listWidth: listWidth,101+ listHeight: bodyHeight,102+ detailWidth: detailWidth,103+ detailHeight: bodyHeight,104+ }105+}106+107+func selectedCategoryName(item list.Item) string {108+ entry, ok := selectedCategoryEntry(item)109+ if !ok {110+ return ""111+ }112+ return entry.Definition.Name113+}114+115+func activeFilterList(m model) *list.Model {116+ switch m.state {117+ case gameSelectView:118+ return &m.nav.gameSelectList119+ case themeSelectView:120+ return &m.theme.list121+ default:122+ return nil123+ }124+}125+126+func (m model) updateCategoryDetailViewport() model {127+ metrics := categoryPickerSize(m.width, m.height)128+ contentWidth := max(metrics.detailWidth-categoryDetailTrimX, 1)129+ contentHeight := max(metrics.detailHeight-categoryDetailTrimY, 1)130+131+ if m.nav.categoryDetail.Width() == 0 || m.nav.categoryDetail.Height() == 0 {132+ m.nav.categoryDetail = viewport.New(133+ viewport.WithWidth(contentWidth),134+ viewport.WithHeight(contentHeight),135+ )136+ }137+ m.nav.categoryDetail.SetWidth(contentWidth)138+ m.nav.categoryDetail.SetHeight(contentHeight)139+ m.nav.categoryDetail.FillHeight = true140+141+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())142+ if !ok {143+ m.nav.categoryDetail.SetContent("")144+ return m145+ }146+147+ m.nav.categoryDetail.SetContent(renderCategoryDetailContent(entry, contentWidth))148+ m.nav.categoryDetail.GotoTop()149+ return m150+}151+48152 func (m model) updateHelpDetailViewport() model {49153 helpWidth, helpHeight := helpViewportSize(m.width, m.height)50- if m.help.renderer == nil || m.help.rendererWidth != helpWidth {154+ palette := theme.Current()155+ themeKey := helpMarkdownThemeKey(palette)156+ if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {51157 renderer, err := glamour.NewTermRenderer(52- glamour.WithAutoStyle(),158+ glamour.WithStyles(helpMarkdownStyle(palette)),53159 glamour.WithWordWrap(helpWidth),160+ glamour.WithChromaFormatter("terminal16m"),54161 )55162 if err != nil {56163 log.Printf("failed to create help renderer: %v", err)57164 m.help.renderer = nil58165 m.help.rendererWidth = 0166+ m.help.rendererTheme = ""59167 } else {60168 m.help.renderer = renderer61169 m.help.rendererWidth = helpWidth170+ m.help.rendererTheme = themeKey62171 }63172 }64173
@@ -84,7 +193,7 @@ func (m model) updateStatsViewport() model {
84193 statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)85194 m.stats.viewport.SetWidth(statsWidth)86195 m.stats.viewport.SetHeight(statsHeight)87- m.stats.viewport.SetContent(stats.RenderCardGrid(m.stats.cards, statsWidth))196+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))88197 return m89198 }90199
@@ -115,7 +224,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
115224 case optionsMenuView:116225 updateMainMenuCursor(msg, &m.nav.optionsMenu)117226 case seedInputView:118- m.nav.seedInput, cmd = m.nav.seedInput.Update(msg)227+ m, cmd = m.handleSeedInputUpdate(msg)119228 case generatingView:120229 m.spinner, cmd = m.spinner.Update(msg)121230 case gameView:
@@ -125,11 +234,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
125234 }126235 m = m.persistCompletionIfSolved()127236 case gameSelectView:237+ prev := selectedCategoryName(m.nav.gameSelectList.SelectedItem())128238 m.nav.gameSelectList, cmd = m.nav.gameSelectList.Update(msg)239+ if selectedCategoryName(m.nav.gameSelectList.SelectedItem()) != prev {240+ m = m.updateCategoryDetailViewport()241+ }129242 case modeSelectView:130243 m.nav.modeSelectList, cmd = m.nav.modeSelectList.Update(msg)131244 case continueView:132245 m.nav.continueTable, cmd = m.nav.continueTable.Update(msg)246+ case weeklyView:247+ m.nav.weeklyTable, cmd = m.nav.weeklyTable.Update(msg)133248 case helpSelectView:134249 m.nav.helpSelectList, cmd = m.nav.helpSelectList.Update(msg)135250 case helpDetailView:
@@ -154,7 +269,9 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
154269 m.width = msg.Width155270 m.height = msg.Height156271 menuW := min(m.width, 64)157- m.nav.gameSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.gameSelectList)))272+ metrics := categoryPickerSize(m.width, m.height)273+ m.nav.gameSelectList.SetSize(metrics.listWidth, metrics.listHeight)274+ m = m.updateCategoryDetailViewport()158275 if m.state == seedInputView {159276 m.nav.seedInput.SetWidth(min(m.width, 48))160277 }
@@ -166,8 +283,12 @@ func (m model) handleWindowSize(msg tea.WindowSizeMsg) model {
166283 visibleRows := min(len(m.nav.continueGames), ui.MaxTableRows)167284 m.nav.continueTable.SetHeight(min(m.height, visibleRows))168285 }286+ if m.state == weeklyView {287+ m = m.refreshWeeklyBrowser()288+ }169289 if m.state == helpSelectView {170- m.nav.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.nav.helpSelectList)))290+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)291+ m.nav.helpSelectList.SetSize(listWidth, listHeight)171292 }172293 if m.state == helpDetailView {173294 m = m.updateHelpDetailViewport()
@@ -197,11 +318,48 @@ func (m model) handleGlobalKey(msg tea.KeyPressMsg) (model, tea.Cmd, bool) {
197318 return m, nil, true198319 }199320 321+ if l := activeFilterList(m); l != nil {322+ switch {323+ case l.SettingFilter() && l.FilterValue() == "" && key.Matches(msg, rootKeys.Enter):324+ l.ResetFilter()325+ if m.state == gameSelectView {326+ m = m.updateCategoryDetailViewport()327+ }328+ return m, nil, true329+ case l.SettingFilter() && key.Matches(msg, rootKeys.Enter):330+ return m, nil, false331+ case l.FilterState() != list.Unfiltered && key.Matches(msg, rootKeys.Escape):332+ return m, nil, false333+ }334+ }335+200336 switch {201- case key.Matches(msg, rootKeys.MainMenu):337+ case m.state == weeklyView && (msg.String() == "left" || msg.String() == "h"):338+ m = m.moveWeeklyWeek(-1)339+ return m, nil, true340+ case m.state == weeklyView && (msg.String() == "right" || msg.String() == "l"):341+ m = m.moveWeeklyWeek(1)342+ return m, nil, true343+ case m.state == gameView && key.Matches(msg, rootKeys.Enter):344+ next, cmd, handled := m.advanceSolvedWeekly()345+ if handled {346+ return next, cmd, true347+ }348+ case m.state == gameSelectView && msg.String() == "pgup":349+ m.nav.categoryDetail.PageUp()350+ return m, nil, true351+ case m.state == gameSelectView && msg.String() == "pgdown":352+ m.nav.categoryDetail.PageDown()353+ return m, nil, true354+ case m.state == gameView && key.Matches(msg, rootKeys.Escape):355+ returnState := m.session.returnState202356 m = saveCurrentGame(m, store.StatusInProgress)203- m.state = mainMenuView357+ m.state = returnState358+ if returnState == weeklyView {359+ m = m.refreshWeeklyBrowser()360+ }204361 m.debug.enabled = false362+ return m, nil, true205363 case key.Matches(msg, rootKeys.Enter):206364 if m.state != gameView {207365 next, cmd := m.handleEnter()
@@ -239,7 +397,8 @@ func (m model) activeSpawnReturnState() viewState {
239397 }240398 241399 func (m model) persistCompletionIfSolved() model {242- if m.session.game == nil || m.session.completionSaved || !m.session.game.IsSolved() {400+ if m.session.game == nil || m.session.activeGameID == 0 ||401+ m.session.completionSaved || !m.session.game.IsSolved() {243402 return m244403 }245404
@@ -285,6 +444,8 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
285444 return m.handleModeSelectEnter()286445 case continueView:287446 return m.handleContinueEnter()447+ case weeklyView:448+ return m.handleWeeklyEnter()288449 case helpSelectView:289450 return m.handleHelpSelectEnter()290451 case themeSelectView:
@@ -297,7 +458,7 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
297458 item := m.nav.mainMenu.Selected()298459 switch item.Title() {299460 case "Play":300- m.nav.playMenu = ui.NewMainMenu(playMenuItems)461+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))301462 m.state = playMenuView302463 case "Stats":303464 return m.handleStatsEnter()
@@ -310,24 +471,40 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
310471 return m, nil311472 }312473 474+func (m model) currentWeeklyMenuIndex() int {475+ if m.store == nil {476+ return 1477+ }478+479+ year, week := time.Now().ISOWeek()480+ highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)481+ if err != nil {482+ return 1483+ }484+ if highestCompleted >= weeklyEntryCount {485+ return weeklyEntryCount486+ }487+ if highestCompleted < 1 {488+ return 1489+ }490+ return highestCompleted + 1491+}492+313493 func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {314494 item := m.nav.playMenu.Selected()315495 switch item.Title() {316- case "Daily Puzzle":317- return m.handleDailyPuzzle()318- case "Generate":496+ case "Create":319497 m.state = gameSelectView498+ m = m.updateCategoryDetailViewport()320499 case "Continue":321500 m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)322501 m.state = continueView323- case "Enter Seed":324- ti := textinput.New()325- ti.Placeholder = "any word or phrase"326- ti.CharLimit = 64327- ti.SetWidth(min(m.width, 48))328- m.nav.seedInput = ti329- m.state = seedInputView330- return m, m.nav.seedInput.Focus()502+ case "Daily":503+ return m.handleDailyPuzzle()504+ case "Weekly":505+ return m.enterWeeklyView()506+ case "Seeded":507+ return m.enterSeedInputView()331508 }332509 return m, nil333510 }
@@ -338,8 +515,9 @@ func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {
338515 case "Theme":339516 return m.handleThemeEnter()340517 case "Guides":341- m.nav.helpSelectList = ui.InitList(GameCategories, "How to Play")342- m.nav.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.helpSelectList)))518+ m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")519+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)520+ m.nav.helpSelectList.SetSize(listWidth, listHeight)343521 m.state = helpSelectView344522 }345523 return m, nil
@@ -351,7 +529,12 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
351529 return m, nil352530 }353531 532+ selectedMode := m.currentSeedMode()354533 name := sessionflow.SeededName(seed)534+ if selectedMode.key != "" {535+ name = sessionflow.SeededNameForGame(seed, selectedMode.gameType)536+ }537+355538 rec, err := m.store.GetDailyGame(name)356539 if err != nil {357540 log.Printf("failed to check seeded game: %v", err)
@@ -368,10 +551,21 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
368551 return m, nil369552 }370553 371- spawner, gameType, modeTitle, err := resolve.SeededMode(seed, catalog.All)372- if err != nil {373- log.Printf("failed to select seeded mode: %v", err)374- return m, nil554+ var spawner game.SeededSpawner555+ var gameType string556+ modeTitle := ""557+ if selectedMode.key == "" {558+ spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())559+ if err != nil {560+ log.Printf("failed to select seeded mode: %v", err)561+ return m, nil562+ }563+ } else {564+ spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())565+ if err != nil {566+ log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)567+ return m, nil568+ }375569 }376570 377571 rng := resolve.RNGFromString(seed)
@@ -382,6 +576,7 @@ func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {
382576 gameType: gameType,383577 modeTitle: modeTitle,384578 returnState: playMenuView,579+ exitState: mainMenuView,385580 }386581 m.state = generatingView387582 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))
@@ -421,44 +616,42 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
421616 gameType: gameType,422617 modeTitle: modeTitle,423618 returnState: playMenuView,619+ exitState: mainMenuView,424620 }425621 m.state = generatingView426622 return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))427623 }428624 429625 func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {430- cat, ok := m.nav.gameSelectList.SelectedItem().(game.Category)626+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())431627 if !ok {432628 return m, nil433629 }434- m.nav.selectedCategory = cat435- m.nav.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")630+ m.nav.selectedCategory = entry631+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")436632 m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))437633 m.state = modeSelectView438634 return m, nil439635 }440636 441637 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {442- item := m.nav.modeSelectList.SelectedItem()443- mode, ok := item.(game.Mode)638+ item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())639+ mode, ok := item.(registry.ModeEntry)444640 if !ok {445641 return m, nil446642 }447- spawner, ok := item.(game.Spawner)448- if !ok {449- return m, nil450- }451- m.nav.selectedModeTitle = mode.Title()643+ m.nav.selectedModeTitle = mode.Definition.Title452644 ctx, jobID := m.beginSpawnContext()453645 m.session.spawn = &spawnRequest{454646 source: spawnSourceNormal,455647 name: sessionflow.GenerateUniqueName(m.store),456- gameType: m.nav.selectedCategory.Name,648+ gameType: m.nav.selectedCategory.Definition.Name,457649 modeTitle: m.nav.selectedModeTitle,458650 returnState: modeSelectView,651+ exitState: mainMenuView,459652 }460653 m.state = generatingView461- return m, tea.Batch(m.spinner.Tick, spawnCmd(spawner, ctx, jobID))654+ return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID))462655 }463656 464657 func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
@@ -472,11 +665,11 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
472665 }473666 474667 func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {475- cat, ok := m.nav.helpSelectList.SelectedItem().(game.Category)668+ entry, ok := selectedCategoryEntry(m.nav.helpSelectList.SelectedItem())476669 if !ok {477670 return m, nil478671 }479- m.help.category = cat672+ m.help.category = entry480673 m = m.updateHelpDetailViewport()481674 m.state = helpDetailView482675 return m, nil
@@ -486,7 +679,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
486679 switch m.state {487680 case playMenuView, optionsMenuView, statsView:488681 m.state = mainMenuView489- case seedInputView, gameSelectView, continueView:682+ case seedInputView, gameSelectView, continueView, weeklyView:490683 m.state = playMenuView491684 case generatingView:492685 returnState := m.activeSpawnReturnState()
@@ -494,6 +687,7 @@ func (m model) handleEscape() (tea.Model, tea.Cmd) {
494687 m.state = returnState495688 case modeSelectView:496689 m.state = gameSelectView690+ m = m.updateCategoryDetailViewport()497691 case helpDetailView:498692 m.state = helpSelectView499693 case helpSelectView, themeSelectView:
@@ -576,7 +770,18 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
576770 log.Printf("failed to get daily streak dates: %v", err)577771 return m, nil578772 }773+ weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()774+ if err != nil {775+ log.Printf("failed to get weekly gauntlet completions: %v", err)776+ return m, nil777+ }579778 now := time.Now()779+ currentYear, currentWeek := now.ISOWeek()780+ thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)781+ if err != nil {782+ log.Printf("failed to get current weekly progress: %v", err)783+ return m, nil784+ }580785 currentDaily := false581786 rec, err := m.store.GetDailyGame(daily.Name(now))582787 if err != nil {
@@ -585,15 +790,24 @@ func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {
585790 currentDaily = rec != nil586791 }587792 588- m.stats.cards = stats.BuildCards(catStats, modeStats)589- m.stats.profile = stats.BuildProfileBanner(catStats, modeStats, streakDates, currentDaily)793+ weights := stats.WeightsFromDefinitions(registry.Definitions())794+ m.stats.cards = stats.BuildCards(weights, catStats, modeStats)795+ m.stats.profile = stats.BuildProfileBanner(796+ catStats,797+ modeStats,798+ weights,799+ streakDates,800+ currentDaily,801+ weekliesCompleted,802+ thisWeekHighestIndex,803+ )590804 591805 statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)592806 m.stats.viewport = viewport.New(593807 viewport.WithWidth(statsWidth),594808 viewport.WithHeight(statsHeight),595809 )596- m.stats.viewport.SetContent(stats.RenderCardGrid(m.stats.cards, statsWidth))810+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))597811 m.state = statsView598812 return m, nil599813 }
M app/update_test.go +345 -7
@@ -6,13 +6,17 @@ import (
66 "time"77 88 "charm.land/bubbles/v2/key"9+ "charm.land/bubbles/v2/list"910 "charm.land/bubbles/v2/textinput"1011 tea "charm.land/bubbletea/v2"1112 "github.com/FelineStateMachine/puzzletea/daily"1213 "github.com/FelineStateMachine/puzzletea/game"1314 "github.com/FelineStateMachine/puzzletea/lightsout"15+ "github.com/FelineStateMachine/puzzletea/registry"1416 sessionflow "github.com/FelineStateMachine/puzzletea/session"1517 "github.com/FelineStateMachine/puzzletea/store"18+ "github.com/FelineStateMachine/puzzletea/ui"19+ "github.com/FelineStateMachine/puzzletea/weekly"1620 )1721 1822 type escapeTrackingGame struct {
@@ -20,6 +24,10 @@ type escapeTrackingGame struct {
2024 sawEscape bool2125 }2226 27+type solvedWeeklyGame struct {28+ save []byte29+}30+2331 func (g *escapeTrackingGame) GetDebugInfo() string {2432 return ""2533 }
@@ -61,7 +69,43 @@ func (g *escapeTrackingGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6169 return g, nil6270 }6371 64-func TestGameViewEscapePassesThroughToGame(t *testing.T) {72+func (g *solvedWeeklyGame) GetDebugInfo() string {73+ return ""74+}75+76+func (g *solvedWeeklyGame) GetFullHelp() [][]key.Binding {77+ return nil78+}79+80+func (g *solvedWeeklyGame) GetSave() ([]byte, error) {81+ return g.save, nil82+}83+84+func (g *solvedWeeklyGame) IsSolved() bool {85+ return true86+}87+88+func (g *solvedWeeklyGame) Reset() game.Gamer {89+ return g90+}91+92+func (g *solvedWeeklyGame) SetTitle(string) game.Gamer {93+ return g94+}95+96+func (g *solvedWeeklyGame) Init() tea.Cmd {97+ return nil98+}99+100+func (g *solvedWeeklyGame) View() string {101+ return ""102+}103+104+func (g *solvedWeeklyGame) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {105+ return g, nil106+}107+108+func TestGameViewEscapeReturnsToMainMenu(t *testing.T) {65109 tracker := &escapeTrackingGame{}66110 m := model{67111 state: gameView,
@@ -71,14 +115,14 @@ func TestGameViewEscapePassesThroughToGame(t *testing.T) {
71115 next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})72116 got := next.(model)73117 74- if got.state != gameView {75- t.Fatalf("expected to remain in gameView, got %d", got.state)118+ if got.state != mainMenuView {119+ t.Fatalf("expected to return to mainMenuView, got %d", got.state)76120 }77- if tracker.updateCalls != 1 {78- t.Fatalf("expected game Update to be called once, got %d", tracker.updateCalls)121+ if tracker.updateCalls != 0 {122+ t.Fatalf("expected game Update not to be called, got %d", tracker.updateCalls)79123 }80- if !tracker.sawEscape {81- t.Fatal("expected game to receive escape key")124+ if tracker.sawEscape {125+ t.Fatal("expected escape to be handled by the app before reaching the game")82126 }83127 }84128
@@ -170,6 +214,85 @@ func TestHandleSeedConfirmDoesNotResumeStatusWhenImportFails(t *testing.T) {
170214 }171215 }172216 217+func TestSeedInputSelectorCyclesAndPersistsDefault(t *testing.T) {218+ options := buildSeedModeOptions(registry.Definitions())219+ if len(options) < 2 {220+ t.Fatal("expected at least one seeded game option in addition to Random")221+ }222+223+ m := model{224+ state: seedInputView,225+ nav: navigationState{226+ seedModeOptions: options,227+ seedFocus: seedFocusMode,228+ },229+ }230+231+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyRight})232+ got := next.(model)233+234+ if got.nav.seedModeIndex != 1 {235+ t.Fatalf("seedModeIndex = %d, want 1", got.nav.seedModeIndex)236+ }237+ if got.nav.lastSeedModeKey != options[1].key {238+ t.Fatalf("lastSeedModeKey = %q, want %q", got.nav.lastSeedModeKey, options[1].key)239+ }240+241+ reopenedModel, _ := (model{242+ state: playMenuView,243+ nav: navigationState{244+ lastSeedModeKey: got.nav.lastSeedModeKey,245+ },246+ }).enterSeedInputView()247+248+ if reopenedModel.state != seedInputView {249+ t.Fatalf("state = %d, want %d (seedInputView)", reopenedModel.state, seedInputView)250+ }251+ if reopenedModel.currentSeedMode().key != options[1].key {252+ t.Fatalf("reopened seed mode key = %q, want %q", reopenedModel.currentSeedMode().key, options[1].key)253+ }254+}255+256+func TestHandleSeedConfirmUsesSelectedSpecificMode(t *testing.T) {257+ s := openAppTestStore(t)258+ options := buildSeedModeOptions(registry.Definitions())259+ if len(options) < 2 {260+ t.Fatal("expected at least one seeded game option in addition to Random")261+ }262+263+ ti := textinput.New()264+ ti.SetValue("specific-mode-seed")265+266+ m := model{267+ state: seedInputView,268+ store: s,269+ nav: navigationState{270+ seedInput: ti,271+ seedModeOptions: options,272+ seedModeIndex: 1,273+ },274+ }275+276+ next, _ := m.handleSeedConfirm()277+ got := next.(model)278+279+ if got.state != generatingView {280+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)281+ }282+ if got.session.spawn == nil {283+ t.Fatal("expected spawn request to be populated")284+ }285+ if got.session.spawn.gameType != options[1].gameType {286+ t.Fatalf("gameType = %q, want %q", got.session.spawn.gameType, options[1].gameType)287+ }288+ if got.session.spawn.modeTitle == "" {289+ t.Fatal("expected a deterministic mode to be selected within the chosen game")290+ }291+ if got.session.spawn.name == sessionflow.SeededName("specific-mode-seed") {292+ t.Fatal("specific seeded game should not reuse the random seeded name")293+ }294+}295+173296 func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {174297 s := openAppTestStore(t)175298 now := time.Now()
@@ -224,6 +347,221 @@ func TestHandleDailyPuzzleDoesNotResumeStatusWhenImportFails(t *testing.T) {
224347 }225348 }226349 350+func TestGameSelectEscapeClearsAppliedFilterBeforeLeavingView(t *testing.T) {351+ l := ui.InitCategoryList(GameCategories, "Select Category")352+ l.SetFilterText("tak")353+ l.SetFilterState(list.FilterApplied)354+355+ m := model{356+ state: gameSelectView,357+ nav: navigationState{358+ gameSelectList: l,359+ },360+ }361+362+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})363+ got := next.(model)364+365+ if got.state != gameSelectView {366+ t.Fatalf("state = %d, want %d (gameSelectView)", got.state, gameSelectView)367+ }368+ if got.nav.gameSelectList.FilterState() != list.Unfiltered {369+ t.Fatalf("filter state = %s, want %s", got.nav.gameSelectList.FilterState(), list.Unfiltered)370+ }371+}372+373+func TestBuildCurrentWeeklyRowsStartsAtOne(t *testing.T) {374+ rows := buildCurrentWeeklyRows(2026, 1, nil)375+ if len(rows) != 1 {376+ t.Fatalf("len(rows) = %d, want 1", len(rows))377+ }378+ if rows[0].Index != 1 || !rows[0].Playable || rows[0].Status != store.StatusNew {379+ t.Fatalf("row[0] = %+v, want index=1 playable status=new", rows[0])380+ }381+}382+383+func TestBuildCurrentWeeklyRowsUnlocksSequentially(t *testing.T) {384+ games := []store.GameRecord{385+ {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},386+ {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},387+ {Name: weekly.Name(2026, 1, 4), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},388+ }389+390+ rows := buildCurrentWeeklyRows(2026, 1, games)391+ if len(rows) != 2 {392+ t.Fatalf("len(rows) = %d, want 2", len(rows))393+ }394+ if rows[0].Index != 2 || !rows[0].Playable {395+ t.Fatalf("row[0] = %+v, want playable #2", rows[0])396+ }397+ if rows[1].Index != 1 || !rows[1].ReadOnly {398+ t.Fatalf("row[1] = %+v, want read-only #1", rows[1])399+ }400+}401+402+func TestBuildReviewWeeklyRowsOnlyIncludesCompleted(t *testing.T) {403+ games := []store.GameRecord{404+ {Name: weekly.Name(2026, 1, 1), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},405+ {Name: weekly.Name(2026, 1, 2), GameType: "Sudoku", Mode: "Easy", Status: store.StatusInProgress},406+ {Name: weekly.Name(2026, 1, 3), GameType: "Sudoku", Mode: "Easy", Status: store.StatusCompleted},407+ }408+409+ rows := buildReviewWeeklyRows(games)410+ if len(rows) != 2 {411+ t.Fatalf("len(rows) = %d, want 2", len(rows))412+ }413+ if rows[0].Index != 3 || rows[1].Index != 1 {414+ t.Fatalf("rows = %+v, want descending completed rows", rows)415+ }416+}417+418+func TestImportAndActivateWeeklyReviewRecordSkipsSaveBinding(t *testing.T) {419+ loadedGame, err := lightsout.New(3, 3)420+ if err != nil {421+ t.Fatal(err)422+ }423+ save, err := loadedGame.GetSave()424+ if err != nil {425+ t.Fatal(err)426+ }427+428+ rec := store.GameRecord{429+ ID: 42,430+ Name: weekly.Name(2026, 1, 1),431+ GameType: "Lights Out",432+ SaveState: string(save),433+ Status: store.StatusCompleted,434+ }435+436+ next, ok := (model{437+ state: weeklyView,438+ width: 80,439+ height: 24,440+ }).importAndActivateRecordWithOptions(rec, gameOpenOptions{441+ readOnly: true,442+ returnState: weeklyView,443+ })444+ if !ok {445+ t.Fatal("expected import to succeed")446+ }447+ if next.session.activeGameID != 0 {448+ t.Fatalf("activeGameID = %d, want 0", next.session.activeGameID)449+ }450+ if next.session.returnState != weeklyView {451+ t.Fatalf("returnState = %d, want %d", next.session.returnState, weeklyView)452+ }453+}454+455+func TestEnterOnSolvedLatestWeeklyCompletesAndQueuesNext(t *testing.T) {456+ s := openAppTestStore(t)457+ now := time.Now()458+ year, weekNumber := now.ISOWeek()459+ currentName := weekly.Name(year, weekNumber, 1)460+461+ rec := &store.GameRecord{462+ Name: currentName,463+ GameType: "Lights Out",464+ Mode: "Easy",465+ InitialState: "{}",466+ SaveState: "{}",467+ Status: store.StatusInProgress,468+ }469+ if err := s.CreateGame(rec); err != nil {470+ t.Fatal(err)471+ }472+473+ m := model{474+ state: gameView,475+ store: s,476+ spinner: newSpinner(),477+ session: sessionState{478+ game: &solvedWeeklyGame{save: []byte(`{"done":true}`)},479+ activeGameID: rec.ID,480+ returnState: weeklyView,481+ weeklyAdvance: &weekly.Info{Year: year, Week: weekNumber, Index: 1},482+ completionSaved: false,483+ },484+ nav: navigationState{485+ weeklyCursor: weekly.StartOfWeek(year, weekNumber, time.Local),486+ },487+ }488+489+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})490+ got := next.(model)491+492+ if got.state != generatingView {493+ t.Fatalf("state = %d, want %d (generatingView)", got.state, generatingView)494+ }495+ if got.session.spawn == nil {496+ t.Fatal("expected spawn request")497+ }498+ if got.session.spawn.name != weekly.Name(year, weekNumber, 2) {499+ t.Fatalf("spawn name = %q, want %q", got.session.spawn.name, weekly.Name(year, weekNumber, 2))500+ }501+502+ saved, err := s.GetWeeklyGame(year, weekNumber, 1)503+ if err != nil {504+ t.Fatal(err)505+ }506+ if saved == nil {507+ t.Fatal("expected weekly record")508+ }509+ if saved.Status != store.StatusCompleted {510+ t.Fatalf("status = %q, want %q", saved.Status, store.StatusCompleted)511+ }512+}513+514+func TestMoveWeeklyWeekDoesNotAdvancePastCurrentWeek(t *testing.T) {515+ s := openAppTestStore(t)516+ current := weekly.Current(time.Now())517+ currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)518+519+ m := model{520+ store: s,521+ nav: navigationState{522+ weeklyCursor: currentCursor,523+ },524+ }525+526+ got := m.moveWeeklyWeek(1)527+ if !got.nav.weeklyCursor.Equal(currentCursor) {528+ t.Fatalf("weeklyCursor = %v, want %v", got.nav.weeklyCursor, currentCursor)529+ }530+}531+532+func TestCurrentWeeklyMenuIndexTracksNextPlayableSlot(t *testing.T) {533+ s := openAppTestStore(t)534+ year, weekNumber := time.Now().ISOWeek()535+536+ for index := 1; index <= 2; index++ {537+ rec := &store.GameRecord{538+ Name: weekly.Name(year, weekNumber, index),539+ GameType: "Nonogram",540+ Mode: "Easy",541+ InitialState: "{}",542+ SaveState: "{}",543+ Status: store.StatusCompleted,544+ }545+ if err := s.CreateGame(rec); err != nil {546+ t.Fatal(err)547+ }548+ }549+550+ m := model{store: s}551+ if got := m.currentWeeklyMenuIndex(); got != 3 {552+ t.Fatalf("currentWeeklyMenuIndex() = %d, want 3", got)553+ }554+}555+556+func TestBuildPlayMenuItemsShowsCurrentWeeklyChallenge(t *testing.T) {557+ now := time.Date(2026, time.March, 7, 12, 0, 0, 0, time.Local)558+559+ items := buildPlayMenuItems(now, 7)560+ if got := items[3].Desc; got != "Week 10-2026 # 7" {561+ t.Fatalf("weekly desc = %q, want %q", got, "Week 10-2026 # 7")562+ }563+}564+227565 func openAppTestStore(t *testing.T) *store.Store {228566 t.Helper()229567 s, err := store.Open(filepath.Join(t.TempDir(), "test.db"))
M app/view.go +156 -15
@@ -2,8 +2,9 @@ package app
22 33 import (44 "fmt"5+ "strings"56 6- "github.com/FelineStateMachine/puzzletea/stats"7+ "github.com/FelineStateMachine/puzzletea/registry"78 "github.com/FelineStateMachine/puzzletea/theme"89 "github.com/FelineStateMachine/puzzletea/ui"910
@@ -26,20 +27,15 @@ func (m model) View() tea.View {
2627 case seedInputView:2728 panel := ui.Panel(2829 "Enter Seed",29- m.nav.seedInput.View(),30- "enter confirm • esc back",30+ m.seedInputBody(),31+ "↑/↓ change field • ←/→ game • enter confirm • esc back",3132 )3233 content = ui.CenterView(m.width, m.height, panel)3334 case gameSelectView:34- panel := ui.Panel(35- "Select Category",36- m.nav.gameSelectList.View(),37- "↑/↓ navigate • enter select • esc back",38- )39- content = ui.CenterView(m.width, m.height, panel)35+ content = m.gameSelectViewContent()4036 case modeSelectView:4137 panel := ui.Panel(42- m.nav.selectedCategory.Name+" — Select Mode",38+ m.nav.selectedCategory.Definition.Name+" — Select Mode",4339 m.nav.modeSelectList.View(),4440 "↑/↓ navigate • enter select • esc back",4541 )
@@ -68,6 +64,8 @@ func (m model) View() tea.View {
6864 )6965 }7066 content = ui.CenterView(m.width, m.height, s)67+ case weeklyView:68+ content = ui.CenterView(m.width, m.height, m.weeklyViewContent())7169 case gameView:7270 if m.session.game == nil {7371 content = ""
@@ -91,7 +89,7 @@ func (m model) View() tea.View {
9189 content = ui.CenterView(m.width, m.height, panel)9290 case helpDetailView:9391 panel := ui.Panel(94- m.help.category.Name+" — Guide",92+ m.help.category.Definition.Name+" — Guide",9593 m.help.viewport.View(),9694 "↑/↓ scroll • esc back",9795 )
@@ -104,7 +102,7 @@ func (m model) View() tea.View {
104102 if len(m.stats.cards) == 0 {105103 statsBody = m.stats.viewport.View()106104 } else {107- banner := stats.RenderBanner(m.stats.profile, statsWidth)105+ banner := ui.RenderStatsBanner(m.stats.profile, statsWidth)108106 statsBody = lipgloss.JoinVertical(lipgloss.Left,109107 banner,110108 "",
@@ -131,6 +129,151 @@ func (m model) View() tea.View {
131129 return v132130 }133131 132+func (m model) gameSelectViewContent() string {133+ metrics := categoryPickerSize(m.width, m.height)134+135+ listView := lipgloss.NewStyle().136+ Width(metrics.listWidth).137+ Height(metrics.listHeight).138+ Border(lipgloss.NormalBorder(), false, true, false, false).139+ BorderForeground(theme.Current().Border).140+ Render(m.nav.gameSelectList.View())141+142+ detailBox := lipgloss.NewStyle().143+ Width(metrics.detailWidth).144+ Height(metrics.detailHeight).145+ Padding(2, 3).146+ Render(m.nav.categoryDetail.View())147+148+ body := lipgloss.JoinHorizontal(lipgloss.Top, listView, strings.Repeat(" ", categoryGapWidth), detailBox)149+ if metrics.stacked {150+ body = lipgloss.JoinVertical(lipgloss.Left, listView, "", detailBox)151+ }152+ body = lipgloss.NewStyle().153+ Width(metrics.bodyWidth).154+ Height(metrics.bodyHeight).155+ Render(body)156+157+ panel := ui.Panel(158+ "Select Category",159+ body,160+ "↑/↓ navigate • / filter • enter select • esc back",161+ )162+ return ui.CenterView(m.width, m.height, panel)163+}164+165+func (m model) weeklyViewContent() string {166+ title := "Weekly Gauntlet — " + m.weeklyPanelTitle()167+ if len(m.nav.weeklyRows) == 0 {168+ body := "No completed puzzles for this week yet."169+ if m.isCurrentWeeklySelection() {170+ body = "No weekly puzzles are available."171+ }172+ return ui.Panel(173+ title,174+ body,175+ "←/→ week • esc back",176+ )177+ }178+179+ footer := "←/→ week • enter open • esc back"180+ if !m.isCurrentWeeklySelection() {181+ footer = "←/→ week • enter review • esc back"182+ }183+ if pg := ui.TablePagination(m.nav.weeklyTable); pg != "" {184+ footer = pg + " " + footer185+ }186+187+ description := m.nav.weeklyTable.View()188+ if !m.isCurrentWeeklySelection() {189+ description = lipgloss.JoinVertical(190+ lipgloss.Left,191+ ui.DimItemStyle().Render("Review only: completed puzzles from this week."),192+ "",193+ description,194+ )195+ }196+197+ return ui.Panel(title, description, footer)198+}199+200+func categoryPickerListHeight() int {201+ const (202+ maxVisibleItems = 8203+ filterChrome = 2204+ )205+206+ return maxVisibleItems + filterChrome207+}208+209+func renderCategoryDetailContent(entry registry.Entry, width int) string {210+ p := theme.Current()211+ desc := lipgloss.NewStyle().212+ Foreground(p.FG).213+ Width(width).214+ Render(entry.Definition.Description)215+216+ meta := ui.DimItemStyle().Render(fmt.Sprintf("%d modes available", len(entry.Modes)))217+ content := lipgloss.JoinVertical(lipgloss.Left,218+ ui.PanelTitle().Render(entry.Definition.Name),219+ meta,220+ "",221+ desc,222+ "",223+ ui.SelectedItemStyle().Render("Modes"),224+ renderModeList(entry, width),225+ )226+ return content227+}228+229+func renderModeList(entry registry.Entry, width int) string {230+ if len(entry.Modes) == 0 {231+ return ui.DimItemStyle().Render("No modes available.")232+ }233+234+ displayTitles := modeDisplayTitles(entry)235+ modeNames := make([]string, 0, len(displayTitles))236+ for _, title := range displayTitles {237+ modeNames = append(modeNames, "• "+title)238+ }239+ if len(modeNames) == 0 {240+ return ui.DimItemStyle().Render("No modes available.")241+ }242+243+ if width < 24 || len(modeNames) == 1 {244+ lines := make([]string, 0, len(modeNames))245+ for _, name := range modeNames {246+ lines = append(lines, lipgloss.NewStyle().Width(width).Render(name))247+ }248+ return strings.Join(lines, "\n")249+ }250+251+ rows := (len(modeNames) + 1) / 2252+ leftWidth := max(width/2-1, 1)253+ rightWidth := max(width-leftWidth-2, 1)254+255+ left := make([]string, 0, rows)256+ right := make([]string, 0, rows)257+ for i, name := range modeNames {258+ line := lipgloss.NewStyle().Width(leftWidth).Render(name)259+ if i < rows {260+ left = append(left, line)261+ continue262+ }263+ line = lipgloss.NewStyle().Width(rightWidth).Render(name)264+ right = append(right, line)265+ }266+ for len(right) < len(left) {267+ right = append(right, strings.Repeat(" ", rightWidth))268+ }269+270+ lines := make([]string, 0, rows)271+ for i := range left {272+ lines = append(lines, lipgloss.JoinHorizontal(lipgloss.Top, left[i], " ", right[i]))273+ }274+ return strings.Join(lines, "\n")275+}276+134277 // themeSelectViewContent renders the theme picker as a side-by-side layout:135278 // theme list on the left, color preview panel on the right.136279 func (m model) themeSelectViewContent() string {
@@ -146,9 +289,7 @@ func (m model) themeSelectViewContent() string {
146289 // Panel chrome: border (2) + padding (2) + title (1) + blank (1) + footer (1) + blank (1) = 8147290 const panelChrome = 8148291 innerH := m.height - panelChrome149- if innerH < 10 {150- innerH = 10151- }292+ innerH = max(innerH, 10)152293 153294 // Left side: theme list.154295 listView := m.theme.list.View()
A app/view_test.go +22 -0
A app/weekly.go +311 -0
@@ -0,0 +1,311 @@
1+package app2+3+import (4+ "log"5+ "sort"6+ "strconv"7+ "time"8+9+ "charm.land/bubbles/v2/table"10+ tea "charm.land/bubbletea/v2"11+ sessionflow "github.com/FelineStateMachine/puzzletea/session"12+ "github.com/FelineStateMachine/puzzletea/store"13+ "github.com/FelineStateMachine/puzzletea/ui"14+ "github.com/FelineStateMachine/puzzletea/weekly"15+)16+17+const weeklyEntryCount = 9918+19+type weeklyRow struct {20+ Index int21+ Name string22+ GameType string23+ Mode string24+ BonusXP int25+ Status store.GameStatus26+ Record *store.GameRecord27+ Playable bool28+ ReadOnly bool29+}30+31+func (r weeklyRow) tableRow() table.Row {32+ return table.Row{33+ formatWeeklyIndex(r.Index),34+ r.GameType,35+ r.Mode,36+ formatWeeklyBonus(r.BonusXP),37+ ui.FormatStatus(r.Status),38+ }39+}40+41+func (m model) enterWeeklyView() (tea.Model, tea.Cmd) {42+ current := weekly.Current(time.Now())43+ m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)44+ m = m.refreshWeeklyBrowser()45+ m.state = weeklyView46+ return m, nil47+}48+49+func (m model) refreshWeeklyBrowser() model {50+ year, weekNumber := m.selectedWeek()51+ games, err := m.store.ListWeeklyGames(year, weekNumber)52+ if err != nil {53+ log.Printf("failed to list weekly games: %v", err)54+ games = nil55+ }56+57+ if m.isCurrentWeeklySelection() {58+ m.nav.weeklyRows = buildCurrentWeeklyRows(year, weekNumber, games)59+ } else {60+ m.nav.weeklyRows = buildReviewWeeklyRows(games)61+ }62+63+ rows := make([]table.Row, 0, len(m.nav.weeklyRows))64+ for _, row := range m.nav.weeklyRows {65+ rows = append(rows, row.tableRow())66+ }67+ m.nav.weeklyTable = ui.InitWeeklyTable(rows, m.height)68+ return m69+}70+71+func (m model) isCurrentWeeklySelection() bool {72+ current := weekly.Current(time.Now())73+ year, weekNumber := m.selectedWeek()74+ return current.Year == year && current.Week == weekNumber75+}76+77+func (m model) selectedWeek() (int, int) {78+ cursor := m.nav.weeklyCursor79+ if cursor.IsZero() {80+ current := weekly.Current(time.Now())81+ cursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)82+ }83+ return cursor.ISOWeek()84+}85+86+func (m model) weeklyPanelTitle() string {87+ year, weekNumber := m.selectedWeek()88+ return "Week " + formatTwoDigits(weekNumber) + "-" + strconv.Itoa(year)89+}90+91+func (m model) moveWeeklyWeek(delta int) model {92+ if m.nav.weeklyCursor.IsZero() {93+ current := weekly.Current(time.Now())94+ m.nav.weeklyCursor = weekly.StartOfWeek(current.Year, current.Week, time.Local)95+ }96+97+ nextCursor := weekly.AddWeeks(m.nav.weeklyCursor, delta)98+ current := weekly.Current(time.Now())99+ currentCursor := weekly.StartOfWeek(current.Year, current.Week, time.Local)100+ if nextCursor.After(currentCursor) {101+ nextCursor = currentCursor102+ }103+104+ m.nav.weeklyCursor = nextCursor105+ return m.refreshWeeklyBrowser()106+}107+108+func (m model) handleWeeklyEnter() (tea.Model, tea.Cmd) {109+ idx := m.nav.weeklyTable.Cursor()110+ if idx < 0 || idx >= len(m.nav.weeklyRows) {111+ return m, nil112+ }113+114+ return m.openWeeklyRow(m.nav.weeklyRows[idx])115+}116+117+func (m model) openWeeklyRow(row weeklyRow) (tea.Model, tea.Cmd) {118+ info, ok := weekly.ParseName(row.Name)119+ if !ok {120+ return m, nil121+ }122+123+ options := gameOpenOptions{124+ returnState: weeklyView,125+ }126+ if row.Playable && !row.ReadOnly {127+ infoCopy := info128+ options.weeklyInfo = &infoCopy129+ }130+131+ if row.Record != nil {132+ if row.ReadOnly {133+ m, _ = m.importAndActivateRecordWithOptions(*row.Record, gameOpenOptions{134+ readOnly: true,135+ returnState: weeklyView,136+ })137+ return m, nil138+ }139+140+ var resumed bool141+ m, resumed = m.importAndActivateRecordWithOptions(*row.Record, options)142+ if resumed {143+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, row.Record); err != nil {144+ log.Printf("%v", err)145+ }146+ }147+ return m, nil148+ }149+150+ if !row.Playable {151+ return m, nil152+ }153+154+ spawner, gameType, modeTitle := weekly.Mode(info.Year, info.Week, info.Index)155+ if spawner == nil {156+ log.Printf("no weekly mode available for %s", row.Name)157+ return m, nil158+ }159+160+ rng := weekly.RNG(info.Year, info.Week, info.Index)161+ ctx, jobID := m.beginSpawnContext()162+ infoCopy := info163+ m.session.spawn = &spawnRequest{164+ source: spawnSourceWeekly,165+ name: row.Name,166+ gameType: gameType,167+ modeTitle: modeTitle,168+ returnState: weeklyView,169+ exitState: weeklyView,170+ weeklyInfo: &infoCopy,171+ }172+ m.state = generatingView173+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))174+}175+176+func (m model) advanceSolvedWeekly() (model, tea.Cmd, bool) {177+ if m.state != gameView || m.session.game == nil || !m.session.game.IsSolved() || m.session.weeklyAdvance == nil {178+ return m, nil, false179+ }180+181+ info := *m.session.weeklyAdvance182+ m = m.persistCompletionIfSolved()183+ m.nav.weeklyCursor = weekly.StartOfWeek(info.Year, info.Week, time.Local)184+ m = m.refreshWeeklyBrowser()185+ m.state = weeklyView186+ if len(m.nav.weeklyRows) == 0 || !m.nav.weeklyRows[0].Playable {187+ return m, nil, true188+ }189+190+ next, cmd := m.openWeeklyRow(m.nav.weeklyRows[0])191+ return next.(model), cmd, true192+}193+194+func buildCurrentWeeklyRows(year, weekNumber int, games []store.GameRecord) []weeklyRow {195+ byIndex := make(map[int]store.GameRecord, len(games))196+ completed := make(map[int]store.GameRecord, len(games))197+198+ for _, rec := range games {199+ info, ok := weekly.ParseName(rec.Name)200+ if !ok || info.Year != year || info.Week != weekNumber {201+ continue202+ }203+ byIndex[info.Index] = rec204+ if rec.Status == store.StatusCompleted {205+ completed[info.Index] = rec206+ }207+ }208+209+ prefix := 0210+ for index := 1; index <= weeklyEntryCount; index++ {211+ if _, ok := completed[index]; !ok {212+ break213+ }214+ prefix = index215+ }216+217+ rows := make([]weeklyRow, 0, prefix+1)218+ if prefix < weeklyEntryCount {219+ nextIndex := prefix + 1220+ if rec, ok := byIndex[nextIndex]; ok {221+ recCopy := rec222+ rows = append(rows, weeklyRow{223+ Index: nextIndex,224+ Name: rec.Name,225+ GameType: rec.GameType,226+ Mode: rec.Mode,227+ BonusXP: weekly.BonusXP(nextIndex),228+ Status: rec.Status,229+ Record: &recCopy,230+ Playable: true,231+ })232+ } else {233+ _, gameType, modeTitle := weekly.Mode(year, weekNumber, nextIndex)234+ rows = append(rows, weeklyRow{235+ Index: nextIndex,236+ Name: weekly.Name(year, weekNumber, nextIndex),237+ GameType: gameType,238+ Mode: modeTitle,239+ BonusXP: weekly.BonusXP(nextIndex),240+ Status: store.StatusNew,241+ Playable: true,242+ })243+ }244+ }245+246+ for index := prefix; index >= 1; index-- {247+ rec := completed[index]248+ recCopy := rec249+ rows = append(rows, weeklyRow{250+ Index: index,251+ Name: rec.Name,252+ GameType: rec.GameType,253+ Mode: rec.Mode,254+ BonusXP: weekly.BonusXP(index),255+ Status: rec.Status,256+ Record: &recCopy,257+ ReadOnly: true,258+ })259+ }260+261+ return rows262+}263+264+func buildReviewWeeklyRows(games []store.GameRecord) []weeklyRow {265+ rows := make([]weeklyRow, 0, len(games))266+ for _, rec := range games {267+ info, ok := weekly.ParseName(rec.Name)268+ if !ok || rec.Status != store.StatusCompleted {269+ continue270+ }271+272+ recCopy := rec273+ rows = append(rows, weeklyRow{274+ Index: info.Index,275+ Name: rec.Name,276+ GameType: rec.GameType,277+ Mode: rec.Mode,278+ BonusXP: weekly.BonusXP(info.Index),279+ Status: rec.Status,280+ Record: &recCopy,281+ ReadOnly: true,282+ })283+ }284+285+ sort.Slice(rows, func(i, j int) bool {286+ return rows[i].Index > rows[j].Index287+ })288+ return rows289+}290+291+func formatWeeklyIndex(index int) string {292+ return "#" + formatTwoDigits(index)293+}294+295+func formatWeeklyBonus(bonus int) string {296+ return "+" + strconv.Itoa(bonus)297+}298+299+func formatWeeklyMenuIndex(index int) string {300+ if index < 10 {301+ return " " + strconv.Itoa(index)302+ }303+ return strconv.Itoa(index)304+}305+306+func formatTwoDigits(value int) string {307+ if value < 10 {308+ return "0" + strconv.Itoa(value)309+ }310+ return strconv.Itoa(value)311+}
A architecture_test.go +82 -0
@@ -0,0 +1,82 @@
1+package main_test2+3+import (4+ "go/parser"5+ "go/token"6+ "path/filepath"7+ "strings"8+ "testing"9+)10+11+func TestPuzzlePackageStaysPure(t *testing.T) {12+ assertPackageDoesNotImport(t, "puzzle", []string{13+ "charm.land/bubbletea",14+ "charm.land/bubbles",15+ "charm.land/lipgloss",16+ "github.com/FelineStateMachine/puzzletea/theme",17+ "github.com/FelineStateMachine/puzzletea/pdfexport",18+ })19+}20+21+func TestStatsPackageAvoidsRenderingImports(t *testing.T) {22+ assertPackageDoesNotImport(t, "stats", []string{23+ "charm.land/bubbletea",24+ "charm.land/bubbles",25+ "charm.land/lipgloss",26+ "github.com/FelineStateMachine/puzzletea/theme",27+ "github.com/FelineStateMachine/puzzletea/ui",28+ })29+}30+31+func TestStorePackageDoesNotImportSchedulePackages(t *testing.T) {32+ assertPackageDoesNotImport(t, "store", []string{33+ "github.com/FelineStateMachine/puzzletea/daily",34+ "github.com/FelineStateMachine/puzzletea/weekly",35+ })36+}37+38+func TestCatalogPackageDoesNotImportConcreteGames(t *testing.T) {39+ assertPackageDoesNotImport(t, "catalog", []string{40+ "github.com/FelineStateMachine/puzzletea/fillomino",41+ "github.com/FelineStateMachine/puzzletea/hashiwokakero",42+ "github.com/FelineStateMachine/puzzletea/hitori",43+ "github.com/FelineStateMachine/puzzletea/lightsout",44+ "github.com/FelineStateMachine/puzzletea/nonogram",45+ "github.com/FelineStateMachine/puzzletea/nurikabe",46+ "github.com/FelineStateMachine/puzzletea/rippleeffect",47+ "github.com/FelineStateMachine/puzzletea/shikaku",48+ "github.com/FelineStateMachine/puzzletea/sudoku",49+ "github.com/FelineStateMachine/puzzletea/sudokurgb",50+ "github.com/FelineStateMachine/puzzletea/takuzu",51+ "github.com/FelineStateMachine/puzzletea/takuzuplus",52+ "github.com/FelineStateMachine/puzzletea/wordsearch",53+ })54+}55+56+func assertPackageDoesNotImport(t *testing.T, dir string, forbidden []string) {57+ t.Helper()58+59+ files, err := filepath.Glob(filepath.Join(dir, "*.go"))60+ if err != nil {61+ t.Fatalf("glob %s: %v", dir, err)62+ }63+64+ fset := token.NewFileSet()65+ for _, path := range files {66+ if strings.HasSuffix(path, "_test.go") {67+ continue68+ }69+ file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)70+ if err != nil {71+ t.Fatalf("parse %s: %v", path, err)72+ }73+ for _, imp := range file.Imports {74+ value := strings.Trim(imp.Path.Value, `"`)75+ for _, prefix := range forbidden {76+ if strings.HasPrefix(value, prefix) {77+ t.Fatalf("%s imports forbidden package %q", path, value)78+ }79+ }80+ }81+ }82+}
M catalog/catalog.go +60 -148
@@ -3,98 +3,65 @@ package catalog
33 import (44 "fmt"55 6- "charm.land/bubbles/v2/list"7- "github.com/FelineStateMachine/puzzletea/fillomino"8- "github.com/FelineStateMachine/puzzletea/game"9- "github.com/FelineStateMachine/puzzletea/hashiwokakero"10- "github.com/FelineStateMachine/puzzletea/hitori"11- "github.com/FelineStateMachine/puzzletea/lightsout"12- "github.com/FelineStateMachine/puzzletea/nonogram"13- "github.com/FelineStateMachine/puzzletea/nurikabe"14- "github.com/FelineStateMachine/puzzletea/rippleeffect"15- "github.com/FelineStateMachine/puzzletea/shikaku"16- "github.com/FelineStateMachine/puzzletea/sudoku"17- "github.com/FelineStateMachine/puzzletea/takuzu"18- "github.com/FelineStateMachine/puzzletea/wordsearch"6+ "github.com/FelineStateMachine/puzzletea/puzzle"197 )208 21-// All is the explicit ordered catalog of every puzzle game.22-var All = []game.Definition{23- fillomino.Definition,24- hashiwokakero.Definition,25- hitori.Definition,26- lightsout.Definition,27- nonogram.Definition,28- nurikabe.Definition,29- rippleeffect.Definition,30- shikaku.Definition,31- sudoku.Definition,32- takuzu.Definition,33- wordsearch.Definition,34-}35-36-// DailyEntry is a flattened seeded daily candidate with metadata.379 type DailyEntry struct {38- Spawner game.SeededSpawner39- GameType string10+ GameID puzzle.GameID11+ GameName string12+ ModeID puzzle.ModeID4013 Mode string4114 }4215 43-type index struct {44- categories []game.Category45- items []list.Item46- names []string47- dailies []DailyEntry48- byName map[string]game.Definition49- byAlias map[string]string16+type Index struct {17+ definitions []puzzle.Definition18+ names []string19+ dailies []DailyEntry20+ byName map[string]puzzle.Definition21+ byAlias map[string]string5022 }5123 52-var built = mustBuildIndex(All)53-54-func mustBuildIndex(definitions []game.Definition) index {55- idx, err := buildIndex(definitions)24+func MustBuild(definitions []puzzle.Definition) Index {25+ idx, err := Build(definitions)5626 if err != nil {5727 panic(err)5828 }5929 return idx6030 }6131 62-func buildIndex(definitions []game.Definition) (index, error) {32+func Build(definitions []puzzle.Definition) (Index, error) {6333 if err := Validate(definitions); err != nil {64- return index{}, err34+ return Index{}, err6535 }6636 67- idx := index{68- categories: make([]game.Category, 0, len(definitions)),69- items: make([]list.Item, 0, len(definitions)),70- names: make([]string, 0, len(definitions)),71- dailies: make([]DailyEntry, 0, len(definitions)*2),72- byName: make(map[string]game.Definition, len(definitions)),73- byAlias: make(map[string]string),37+ idx := Index{38+ definitions: append([]puzzle.Definition(nil), definitions...),39+ names: make([]string, 0, len(definitions)),40+ dailies: make([]DailyEntry, 0, len(definitions)*2),41+ byName: make(map[string]puzzle.Definition, len(definitions)),42+ byAlias: make(map[string]string),7443 }7544 7645 for _, def := range definitions {77- category := def.Category()78- idx.categories = append(idx.categories, category)79- idx.items = append(idx.items, category)8046 idx.names = append(idx.names, def.Name)81- idx.byName[game.NormalizeName(def.Name)] = def47+ idx.byName[puzzle.NormalizeName(def.Name)] = def8248 for _, alias := range def.Aliases {83- idx.byAlias[game.NormalizeName(alias)] = def.Name49+ idx.byAlias[puzzle.NormalizeName(alias)] = def.Name8450 }85- for _, item := range def.DailyModes {86- mode, ok := item.(game.Mode)87- if !ok {88- continue89- }90- spawner, ok := item.(game.SeededSpawner)51+ byModeID := make(map[puzzle.ModeID]puzzle.ModeDef, len(def.Modes))52+ for _, mode := range def.Modes {53+ byModeID[mode.ID] = mode54+ }55+ for _, dailyID := range def.DailyModeIDs {56+ mode, ok := byModeID[dailyID]9157 if !ok {9258 continue9359 }9460 idx.dailies = append(idx.dailies, DailyEntry{95- Spawner: spawner,96- GameType: def.Name,97- Mode: mode.Title(),61+ GameID: def.ID,62+ GameName: def.Name,63+ ModeID: mode.ID,64+ Mode: mode.Title,9865 })9966 }10067 }
@@ -102,49 +69,39 @@ func buildIndex(definitions []game.Definition) (index, error) {
10269 return idx, nil10370 }10471 105-// Validate checks that the catalog is internally consistent.106-func Validate(definitions []game.Definition) error {72+func Validate(definitions []puzzle.Definition) error {10773 names := make(map[string]string, len(definitions))10874 aliases := make(map[string]string)10975 11076 for _, def := range definitions {111- if def.Import == nil {112- return fmt.Errorf("game %q has nil importer", def.Name)113- }114-115- normName := game.NormalizeName(def.Name)77+ normName := puzzle.NormalizeName(def.Name)11678 if normName == "" {11779 return fmt.Errorf("game %q has empty canonical name", def.Name)11880 }81+ if def.ID == "" {82+ return fmt.Errorf("game %q has empty id", def.Name)83+ }11984 if prior, exists := names[normName]; exists {12085 return fmt.Errorf("duplicate game name %q conflicts with %q", def.Name, prior)12186 }12287 names[normName] = def.Name12388 124- modeTitles := make(map[string]struct{}, len(def.Modes))125- for _, item := range def.Modes {126- mode, ok := item.(game.Mode)127- if !ok {128- continue89+ modeTitles := make(map[puzzle.ModeID]struct{}, len(def.Modes))90+ for _, mode := range def.Modes {91+ if mode.ID == "" {92+ return fmt.Errorf("game %q has mode %q with empty id", def.Name, mode.Title)12993 }130- modeTitles[mode.Title()] = struct{}{}94+ modeTitles[mode.ID] = struct{}{}13195 }13296 133- for _, item := range def.DailyModes {134- mode, ok := item.(game.Mode)135- if !ok {136- return fmt.Errorf("game %q has daily mode without game.Mode implementation", def.Name)137- }138- if _, ok := item.(game.SeededSpawner); !ok {139- return fmt.Errorf("game %q daily mode %q is not seeded", def.Name, mode.Title())140- }141- if _, ok := modeTitles[mode.Title()]; !ok {142- return fmt.Errorf("game %q daily mode %q is not present in Modes", def.Name, mode.Title())97+ for _, dailyID := range def.DailyModeIDs {98+ if _, ok := modeTitles[dailyID]; !ok {99+ return fmt.Errorf("game %q daily mode %q is not present in Modes", def.Name, dailyID)143100 }144101 }145102 146103 for _, alias := range def.Aliases {147- normAlias := game.NormalizeName(alias)104+ normAlias := puzzle.NormalizeName(alias)148105 if normAlias == "" {149106 return fmt.Errorf("game %q has empty alias", def.Name)150107 }
@@ -164,76 +121,31 @@ func Validate(definitions []game.Definition) error {
164121 return nil165122 }166123 167-// Names returns the canonical game names in catalog order.168-func Names() []string {169- return append([]string(nil), built.names...)170-}171-172-// Categories returns the category view of the catalog in menu order.173-func Categories() []game.Category {174- return append([]game.Category(nil), built.categories...)124+func (i Index) Names() []string {125+ return append([]string(nil), i.names...)175126 }176127 177-// CategoryItems returns the category view as list items for Bubble Tea lists.178-func CategoryItems() []list.Item {179- return append([]list.Item(nil), built.items...)128+func (i Index) Definitions() []puzzle.Definition {129+ return append([]puzzle.Definition(nil), i.definitions...)180130 }181131 182-// DailyEntries returns the flattened daily-eligible seeded modes.183-func DailyEntries() []DailyEntry {184- return append([]DailyEntry(nil), built.dailies...)132+func (i Index) DailyEntries() []DailyEntry {133+ return append([]DailyEntry(nil), i.dailies...)185134 }186135 187-// Lookup returns a definition by canonical name.188-func Lookup(name string) (game.Definition, bool) {189- def, ok := built.byName[game.NormalizeName(name)]136+func (i Index) Lookup(name string) (puzzle.Definition, bool) {137+ def, ok := i.byName[puzzle.NormalizeName(name)]190138 return def, ok191139 }192140 193-// Resolve returns a definition by canonical name or alias.194-func Resolve(name string) (game.Definition, bool) {195- norm := game.NormalizeName(name)196- if def, ok := built.byName[norm]; ok {141+func (i Index) Resolve(name string) (puzzle.Definition, bool) {142+ norm := puzzle.NormalizeName(name)143+ if def, ok := i.byName[norm]; ok {197144 return def, true198145 }199- canonical, ok := built.byAlias[norm]200- if !ok {201- return game.Definition{}, false202- }203- return Lookup(canonical)204-}205-206-// Category resolves a canonical game name or alias to the menu-facing category.207-func Category(name string) (game.Category, error) {208- def, ok := Resolve(name)209- if !ok {210- return game.Category{}, fmt.Errorf("unknown game %q\n\nAvailable games:\n %s",211- name, joinLines(Names()))212- }213- return def.Category(), nil214-}215-216-// Import reconstructs a saved game for the given canonical game type.217-func Import(gameType string, data []byte) (game.Gamer, error) {218- def, ok := Lookup(gameType)146+ canonical, ok := i.byAlias[norm]219147 if !ok {220- return nil, fmt.Errorf("unknown game type %q in save data", gameType)221- }222- g, err := def.Import(data)223- if err != nil {224- return nil, fmt.Errorf("failed to import game: %w", err)225- }226- return g, nil227-}228-229-func joinLines(values []string) string {230- if len(values) == 0 {231- return ""232- }233-234- result := values[0]235- for i := 1; i < len(values); i++ {236- result += "\n " + values[i]148+ return puzzle.Definition{}, false237149 }238- return result150+ return i.Lookup(canonical)239151 }
M catalog/catalog_test.go +21 -85
@@ -1,24 +1,27 @@
1-package catalog1+package catalog_test22 33 import (4- "strings"54 "testing"65 7- "github.com/FelineStateMachine/puzzletea/lightsout"6+ "github.com/FelineStateMachine/puzzletea/catalog"7+ "github.com/FelineStateMachine/puzzletea/registry"88 )99 10-func TestValidateAll(t *testing.T) {11- if err := Validate(All); err != nil {12- t.Fatalf("Validate(All) error = %v", err)10+func TestValidateDefinitions(t *testing.T) {11+ definitions := registry.Definitions()12+ if err := catalog.Validate(definitions); err != nil {13+ t.Fatalf("Validate(definitions) error = %v", err)1314 }1415 }1516 1617 func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {18+ idx := catalog.MustBuild(registry.Definitions())1719 tests := []struct {1820 input string1921 want string2022 }{2123 {input: "Sudoku", want: "Sudoku"},24+ {input: "ripeto", want: "Sudoku RGB"},2225 {input: "fillomino", want: "Fillomino"},2326 {input: "hashi", want: "Hashiwokakero"},2427 {input: "lights", want: "Lights Out"},
@@ -27,7 +30,7 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
2730 }2831 2932 for _, tt := range tests {30- def, ok := Resolve(tt.input)33+ def, ok := idx.Resolve(tt.input)3134 if !ok {3235 t.Fatalf("Resolve(%q) returned !ok", tt.input)3336 }
@@ -37,95 +40,28 @@ func TestResolveSupportsCanonicalNamesAndAliases(t *testing.T) {
3740 }3841 }3942 40-func TestCategorySupportsCanonicalNamesAndAliases(t *testing.T) {41- tests := []struct {42- input string43- want string44- }{45- {input: "Sudoku", want: "Sudoku"},46- {input: "lights-out", want: "Lights Out"},47- {input: "polyomino", want: "Fillomino"},48- {input: "wordsearch", want: "Word Search"},49- }50-51- for _, tt := range tests {52- cat, err := Category(tt.input)53- if err != nil {54- t.Fatalf("Category(%q) error = %v", tt.input, err)55- }56- if cat.Name != tt.want {57- t.Fatalf("Category(%q).Name = %q, want %q", tt.input, cat.Name, tt.want)58- }59- }60-}61-62-func TestCategoryUnknownGameIncludesAvailableGames(t *testing.T) {63- _, err := Category("chess")64- if err == nil {65- t.Fatal("Category(chess) error = nil, want non-nil")66- }67- if got := err.Error(); got == "" || !containsAll(got, []string{"unknown game", "Available games:", "Sudoku"}) {68- t.Fatalf("Category(chess) error = %q, want available games list", got)69- }70-}71-72-func TestCategoriesAndNamesPreserveCatalogOrder(t *testing.T) {73- names := Names()74- categories := Categories()75- if len(names) != len(All) {76- t.Fatalf("len(Names()) = %d, want %d", len(names), len(All))77- }78- if len(categories) != len(All) {79- t.Fatalf("len(Categories()) = %d, want %d", len(categories), len(All))80- }81- for i, def := range All {43+func TestDefinitionsAndNamesPreserveRegistryOrder(t *testing.T) {44+ definitions := registry.Definitions()45+ idx := catalog.MustBuild(definitions)46+ names := idx.Names()47+ if len(names) != len(definitions) {48+ t.Fatalf("len(Names()) = %d, want %d", len(names), len(definitions))49+ }50+ for i, def := range definitions {8251 if names[i] != def.Name {8352 t.Fatalf("Names()[%d] = %q, want %q", i, names[i], def.Name)8453 }85- if categories[i].Name != def.Name {86- t.Fatalf("Categories()[%d].Name = %q, want %q", i, categories[i].Name, def.Name)87- }88- }89-}90-91-func containsAll(s string, parts []string) bool {92- for _, part := range parts {93- if !strings.Contains(s, part) {94- return false95- }96- }97- return true98-}99-100-func TestImportRoundTrip(t *testing.T) {101- g, err := lightsout.New(3, 3)102- if err != nil {103- t.Fatal(err)104- }105- save, err := g.GetSave()106- if err != nil {107- t.Fatal(err)108- }109-110- loaded, err := Import("Lights Out", save)111- if err != nil {112- t.Fatalf("Import returned error: %v", err)113- }114- if loaded == nil {115- t.Fatal("Import returned nil game")11654 }11755 }11856 11957 func TestDailyEntriesHaveMetadata(t *testing.T) {120- entries := DailyEntries()58+ idx := catalog.MustBuild(registry.Definitions())59+ entries := idx.DailyEntries()12160 if len(entries) == 0 {12261 t.Fatal("DailyEntries returned no entries")12362 }12463 for i, entry := range entries {125- if entry.Spawner == nil {126- t.Fatalf("DailyEntries()[%d].Spawner is nil", i)127- }128- if entry.GameType == "" || entry.Mode == "" {64+ if entry.GameID == "" || entry.GameName == "" || entry.ModeID == "" || entry.Mode == "" {12965 t.Fatalf("DailyEntries()[%d] missing metadata", i)13066 }13167 }
M cmd/continue.go +0 -4
M cmd/export_pdf.go +9 -13
@@ -7,9 +7,9 @@ import (
77 "strings"88 "time"99 10- "github.com/FelineStateMachine/puzzletea/catalog"11- "github.com/FelineStateMachine/puzzletea/game"1210 "github.com/FelineStateMachine/puzzletea/pdfexport"11+ "github.com/FelineStateMachine/puzzletea/puzzle"12+ "github.com/FelineStateMachine/puzzletea/registry"1313 1414 "github.com/spf13/cobra"1515 )
@@ -53,7 +53,7 @@ func runExportPDF(cmd *cobra.Command, args []string) error {
5353 return nil5454 }5555 56- lookup := buildModeDifficultyLookup(catalog.Categories())56+ lookup := buildModeDifficultyLookup(registry.Definitions())5757 annotateDifficulty(puzzles, lookup)5858 5959 shuffleSeed := strings.TrimSpace(flagPDFShuffleSeed)
@@ -135,17 +135,13 @@ func buildRenderConfigForPDF(docs []pdfexport.PackDocument, shuffleSeed string,
135135 return cfg, nil136136 }137137 138-func buildModeDifficultyLookup(categories []game.Category) map[string]map[string]float64 {139- lookup := make(map[string]map[string]float64, len(categories))138+func buildModeDifficultyLookup(definitions []puzzle.Definition) map[string]map[string]float64 {139+ lookup := make(map[string]map[string]float64, len(definitions))140140 141- for _, cat := range categories {141+ for _, def := range definitions {142142 titles := []string{}143- for _, item := range cat.Modes {144- mode, ok := item.(game.Mode)145- if !ok {146- continue147- }148- title := strings.TrimSpace(mode.Title())143+ for _, mode := range def.Modes {144+ title := strings.TrimSpace(mode.Title)149145 if title == "" {150146 continue151147 }
@@ -165,7 +161,7 @@ func buildModeDifficultyLookup(categories []game.Category) map[string]map[string
165161 }166162 }167163 168- lookup[normalizeDifficultyToken(cat.Name)] = scores164+ lookup[normalizeDifficultyToken(def.Name)] = scores169165 }170166 171167 return lookup
M cmd/new.go +8 -12
@@ -4,12 +4,11 @@ import (
44 "fmt"55 "strings"66 7- "github.com/FelineStateMachine/puzzletea/catalog"87 "github.com/FelineStateMachine/puzzletea/config"98 "github.com/FelineStateMachine/puzzletea/game"9+ "github.com/FelineStateMachine/puzzletea/registry"1010 "github.com/FelineStateMachine/puzzletea/resolve"1111 sessionflow "github.com/FelineStateMachine/puzzletea/session"12- "github.com/FelineStateMachine/puzzletea/stats"1312 "github.com/FelineStateMachine/puzzletea/store"1413 1514 "github.com/spf13/cobra"
@@ -27,7 +26,7 @@ var newCmd = &cobra.Command{
2726 Short: "Start a new puzzle game",2827 Long: fmt.Sprintf("Start a new puzzle game, optionally specifying the difficulty mode.\n"+2928 "Use --set-seed to generate a deterministic puzzle from a seed string.\n\nAvailable games:\n %s",30- strings.Join(catalog.Names(), "\n ")),29+ strings.Join(registry.Names(), "\n ")),3130 Args: cobra.RangeArgs(0, 2),3231 RunE: func(cmd *cobra.Command, args []string) error {3332 if flagOutput != "" || cmd.Flags().Changed("export") {
@@ -63,12 +62,13 @@ func init() {
6362 6463 // launchNewGame resolves the game/mode, spawns a new game, and launches the TUI.6564 func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {66- cat, err := catalog.Category(gameArg)67- if err != nil {65+ entry, ok := registry.Resolve(gameArg)66+ if !ok {67+ err := fmt.Errorf("unknown game %q", gameArg)6868 return err6969 }7070 71- spawner, modeTitle, err := resolve.Mode(cat, modeArg)71+ spawner, modeTitle, err := resolve.Mode(entry, modeArg)7272 if err != nil {7373 return err7474 }
@@ -84,12 +84,10 @@ func launchNewGame(gameArg, modeArg, seed string, cfg *config.Config) error {
8484 }8585 defer s.Close()8686 87- stats.InitModeXP(catalog.Categories())88-8987 name := sessionflow.GenerateUniqueName(s)9088 g = g.SetTitle(name)9189 92- rec, err := sessionflow.CreateRecord(s, g, name, cat.Name, modeTitle)90+ rec, err := sessionflow.CreateRecord(s, g, name, entry.Definition.Name, modeTitle)9391 if err != nil {9492 return err9593 }
@@ -126,8 +124,6 @@ func launchSeededGame(seed string, cfg *config.Config) error {
126124 }127125 defer s.Close()128126 129- stats.InitModeXP(catalog.Categories())130-131127 // If a game with this name already exists, resume it (including132128 // abandoned games — seeded puzzles are deterministic and should133129 // always be resumable rather than duplicated).
@@ -146,7 +142,7 @@ func launchSeededGame(seed string, cfg *config.Config) error {
146142 }147143 148144 // Mode selection uses rendezvous hashing (independent of RNG).149- spawner, gameType, modeTitle, err := resolve.SeededMode(seed, catalog.All)145+ spawner, gameType, modeTitle, err := resolve.SeededMode(seed, registry.Entries())150146 if err != nil {151147 return err152148 }
M cmd/new_export.go +16 -24
@@ -10,10 +10,10 @@ import (
1010 "strings"1111 "time"1212 13- "github.com/FelineStateMachine/puzzletea/catalog"1413 "github.com/FelineStateMachine/puzzletea/game"1514 "github.com/FelineStateMachine/puzzletea/namegen"1615 "github.com/FelineStateMachine/puzzletea/pdfexport"16+ "github.com/FelineStateMachine/puzzletea/registry"1717 "github.com/FelineStateMachine/puzzletea/resolve"1818 1919 "github.com/spf13/cobra"
@@ -31,11 +31,11 @@ func runNewExport(cmd *cobra.Command, args []string) error {
3131 return err3232 }3333 34- cat, err := catalog.Category(args[0])35- if err != nil {36- return err34+ entry, ok := registry.Resolve(args[0])35+ if !ok {36+ return fmt.Errorf("unknown game %q", args[0])3737 }38- if !game.HasPrintAdapter(cat.Name) {38+ if !pdfexport.HasPrintAdapter(entry.Definition.Name) {3939 return nil4040 }4141
@@ -44,13 +44,13 @@ func runNewExport(cmd *cobra.Command, args []string) error {
4444 modeArg = args[1]4545 }4646 47- entries, modeSelection, err := collectExportModes(cat, modeArg)47+ entries, modeSelection, err := collectExportModes(entry, modeArg)4848 if err != nil {4949 return err5050 }5151 5252 generatedAt := exportNow()53- records, err := buildExportRecords(cat.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)53+ records, err := buildExportRecords(entry.Definition.Name, modeSelection, entries, flagExport, flagWithSeed, generatedAt)5454 if err != nil {5555 return err5656 }
@@ -62,7 +62,7 @@ func runNewExport(cmd *cobra.Command, args []string) error {
6262 return nil6363 }6464 65-func validateNewExportFlags(cmd *cobra.Command, args []string) error {65+func validateNewExportFlags(_ *cobra.Command, args []string) error {6666 if flagExport < 1 {6767 return fmt.Errorf("--export must be at least 1")6868 }
@@ -81,32 +81,24 @@ func validateNewExportFlags(cmd *cobra.Command, args []string) error {
8181 return nil8282 }8383 84-func collectExportModes(cat game.Category, modeArg string) ([]exportModeEntry, string, error) {84+func collectExportModes(entry registry.Entry, modeArg string) ([]exportModeEntry, string, error) {8585 if modeArg != "" {86- spawner, modeTitle, err := resolve.Mode(cat, modeArg)86+ spawner, modeTitle, err := resolve.Mode(entry, modeArg)8787 if err != nil {8888 return nil, "", err8989 }9090 return []exportModeEntry{{spawner: spawner, mode: modeTitle}}, modeTitle, nil9191 }9292 93- entries := make([]exportModeEntry, 0, len(cat.Modes))94- for _, item := range cat.Modes {95- mode, ok := item.(game.Mode)96- if !ok {97- continue98- }99- spawner, ok := item.(game.Spawner)100- if !ok {101- continue102- }93+ entries := make([]exportModeEntry, 0, len(entry.Modes))94+ for _, mode := range entry.Modes {10395 entries = append(entries, exportModeEntry{104- spawner: spawner,105- mode: mode.Title(),96+ spawner: mode.Spawner,97+ mode: mode.Definition.Title,10698 })10799 }108100 if len(entries) == 0 {109- return nil, "", fmt.Errorf("game %q has no exportable modes", cat.Name)101+ return nil, "", fmt.Errorf("game %q has no exportable modes", entry.Definition.Name)110102 }111103 112104 return entries, "mixed modes", nil
@@ -131,7 +123,7 @@ func buildExportRecords(
131123 nameRNG := resolve.RNGFromString("export-names:" + nameSeed)132124 133125 records := make([]pdfexport.JSONLRecord, 0, count)134- for i := 0; i < count; i++ {126+ for i := range count {135127 entry := entries[0]136128 if len(entries) > 1 {137129 var modeIndex int
M cmd/root.go +0 -3
M daily/daily.go +4 -4
M daily/daily_test.go +15 -0
M fillomino/Gamemode.go +1 -1
M fillomino/Model.go +7 -1
M fillomino/PrintAdapter.go +1 -2
M fillomino/fillomino_test.go +223 -49
@@ -1,6 +1,7 @@
11 package fillomino22 33 import (4+ "image"45 "image/color"56 "math/rand/v2"67 "strings"
@@ -192,67 +193,201 @@ func TestCellViewConflictedCursorIsDistinct(t *testing.T) {
192193 }193194 }194195 195-func TestDynamicGridEdges(t *testing.T) {196- g := grid{197- {2, 2, 1},198- {3, 3, 1},196+func TestGridViewRendersRegionBoundaries(t *testing.T) {197+ m := Model{198+ width: 3,199+ height: 2,200+ grid: grid{{2, 2, 1}, {2, 3, 1}},201+ provided: newProvidedMask(grid{{2, 2, 1}, {2, 3, 1}}),202+ conflicts: validateGridState(grid{{2, 2, 1}, {2, 3, 1}}).conflicts,199203 }200- h := grid{201- {4, 1},202- {4, 2},204+205+ lines := strings.Split(ansi.Strip(gridView(m)), "\n")206+ content := []rune(lines[1])207+ if got := content[4]; got != ' ' {208+ t.Fatalf("interior separator inside region = %q, want space", got)209+ }210+ if got := content[8]; got != '│' {211+ t.Fatalf("separator between regions = %q, want vertical wall", got)203212 }204213 205- if hasVerticalEdge(g, 1, 0) {206- t.Fatal("expected open interior edge inside a region")214+ boundary := []rune(lines[2])215+ if got := boundary[1]; got != ' ' {216+ t.Fatalf("row separator inside region = %q, want space", got)207217 }208- if !hasVerticalEdge(g, 2, 0) {209- t.Fatal("expected border between different regions")218+ if got := boundary[5]; got != '─' {219+ t.Fatalf("row separator between regions = %q, want horizontal wall", got)210220 }211- if horizontalEdge(h, 0, 1) {212- t.Fatal("expected open horizontal edge inside a region")221+}222+223+func TestBridgeFillUsesCrosshairColorAcrossEmptyBoundary(t *testing.T) {224+ m := Model{225+ width: 2,226+ height: 1,227+ grid: grid{{0, 0}},228+ conflicts: validateGridState(grid{{0, 0}}).conflicts,229+ cursor: pointCursor(0, 0),230+ }231+ renderState := buildRenderGridState(m)232+233+ got := bridgeFill(m, renderState, game.DynamicGridBridge{234+ Kind: game.DynamicGridBridgeVertical,235+ X: 1,236+ Y: 0,237+ Count: 2,238+ Zone: renderState.zones[0][0],239+ Uniform: true,240+ Cells: [4]image.Point{241+ {X: 0, Y: 0},242+ {X: 1, Y: 0},243+ },244+ })245+ if !sameColor(got, theme.Current().Surface) {246+ t.Fatal("expected empty bridge on cursor crosshair to use surface background")213247 }214- if !horizontalEdge(g, 0, 0) {215- t.Fatal("expected top outer border")248+}249+250+func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {251+ m := Model{252+ width: 3,253+ height: 3,254+ grid: grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}},255+ conflicts: validateGridState(grid{{0, 0, 0}, {0, 0, 0}, {0, 0, 0}}).conflicts,256+ cursor: pointCursor(1, 1),257+ }258+ renderState := buildRenderGridState(m)259+260+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{261+ Kind: game.DynamicGridBridgeVertical,262+ X: 1,263+ Y: 1,264+ Count: 2,265+ Zone: renderState.zones[1][0],266+ Uniform: true,267+ Cells: [4]image.Point{268+ {X: 0, Y: 1},269+ {X: 1, Y: 1},270+ },271+ }); got != nil {272+ t.Fatal("expected fully open interior bridge to remain unfilled")216273 }217274 }218275 219-func TestCompletedRegionGapBackgrounds(t *testing.T) {220- conflicts := validateGridState(grid{{4, 4}, {4, 4}}).conflicts276+func TestBridgeFillVerticalUsesRowCrosshairOnly(t *testing.T) {221277 m := Model{222278 width: 2,223279 height: 2,224- grid: grid{{4, 4}, {4, 4}},225- conflicts: conflicts,280+ grid: grid{{0, 0}, {0, 0}},281+ conflicts: validateGridState(grid{{0, 0}, {0, 0}}).conflicts,282+ cursor: pointCursor(0, 1),283+ }284+ renderState := buildRenderGridState(m)285+286+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{287+ Kind: game.DynamicGridBridgeVertical,288+ X: 1,289+ Y: 0,290+ Count: 2,291+ Zone: renderState.zones[0][0],292+ Uniform: true,293+ Cells: [4]image.Point{294+ {X: 0, Y: 0},295+ {X: 1, Y: 0},296+ },297+ }); got != nil {298+ t.Fatal("expected vertical bridge to ignore column-only crosshair match")226299 }227- completed := completedRegionBackgrounds(m.grid, m.conflicts)228- regionSet := map[point]struct{}{}300+}229301 230- if got := verticalGapBackground(m, regionSet, completed, 1, 0); got == nil {231- t.Fatal("expected completed region to color vertical interior gap")302+func TestBridgeFillHorizontalUsesColumnCrosshairOnly(t *testing.T) {303+ m := Model{304+ width: 2,305+ height: 2,306+ grid: grid{{0, 0}, {0, 0}},307+ conflicts: validateGridState(grid{{0, 0}, {0, 0}}).conflicts,308+ cursor: pointCursor(1, 0),309+ }310+ renderState := buildRenderGridState(m)311+312+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{313+ Kind: game.DynamicGridBridgeHorizontal,314+ X: 0,315+ Y: 1,316+ Count: 2,317+ Zone: renderState.zones[0][0],318+ Uniform: true,319+ Cells: [4]image.Point{320+ {X: 0, Y: 0},321+ {X: 0, Y: 1},322+ },323+ }); got != nil {324+ t.Fatal("expected horizontal bridge to ignore row-only crosshair match")232325 }233- if got := horizontalGapBackground(m, regionSet, completed, 0, 1); got == nil {234- t.Fatal("expected completed region to color horizontal interior gap")326+}327+328+func TestBridgeFillExtendsThroughClosedVerticalSeparator(t *testing.T) {329+ m := Model{330+ width: 2,331+ height: 1,332+ grid: grid{{1, 2}},333+ conflicts: validateGridState(grid{{1, 2}}).conflicts,334+ cursor: pointCursor(0, 0),235335 }236- if got := junctionGapBackground(m, regionSet, completed, 1, 1); got == nil {237- t.Fatal("expected completed region to color interior junction")336+ renderState := buildRenderGridState(m)337+338+ got := bridgeFill(m, renderState, game.DynamicGridBridge{339+ Kind: game.DynamicGridBridgeVertical,340+ X: 1,341+ Y: 0,342+ })343+ if !sameColor(got, theme.Current().Surface) {344+ t.Fatal("expected closed vertical separator on cursor row to use surface background")238345 }239346 }240347 241-func TestConflictGapBackgroundUsesConflictColor(t *testing.T) {242- conflicts := validateGridState(grid{{4, 4}, {4, 4}}).conflicts348+func TestBridgeFillExtendsThroughClosedHorizontalSeparator(t *testing.T) {243349 m := Model{244- width: 2,350+ width: 1,245351 height: 2,246- grid: grid{{4, 4}, {4, 4}},247- conflicts: conflicts,352+ grid: grid{{1}, {2}},353+ conflicts: validateGridState(grid{{1}, {2}}).conflicts,354+ cursor: pointCursor(0, 0),248355 }249- m.conflicts[0][0] = true356+ renderState := buildRenderGridState(m)250357 251- if got := verticalGapBackground(m, nil, nil, 1, 0); !sameColor(got, game.ConflictBG()) {252- t.Fatal("expected conflict color to bridge vertical interior gap")358+ got := bridgeFill(m, renderState, game.DynamicGridBridge{359+ Kind: game.DynamicGridBridgeHorizontal,360+ X: 0,361+ Y: 1,362+ })363+ if !sameColor(got, theme.Current().Surface) {364+ t.Fatal("expected closed horizontal separator on cursor column to use surface background")253365 }254- if got := junctionGapBackground(m, nil, nil, 1, 1); !sameColor(got, game.ConflictBG()) {255- t.Fatal("expected conflict color to bridge interior junction")366+}367+368+func TestBridgeFillPreservesActiveRegionBackground(t *testing.T) {369+ m := Model{370+ width: 2,371+ height: 1,372+ grid: grid{{2, 2}},373+ conflicts: validateGridState(grid{{2, 2}}).conflicts,374+ cursor: pointCursor(0, 0),375+ }376+ renderState := buildRenderGridState(m)377+378+ if got := bridgeFill(m, renderState, game.DynamicGridBridge{379+ Kind: game.DynamicGridBridgeVertical,380+ X: 1,381+ Y: 0,382+ Count: 2,383+ Zone: renderState.zones[0][0],384+ Uniform: true,385+ Cells: [4]image.Point{386+ {X: 0, Y: 0},387+ {X: 1, Y: 0},388+ },389+ }); got != nil {390+ t.Fatal("expected active region bridge to defer to region background")256391 }257392 }258393
@@ -302,21 +437,60 @@ func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {
302437 }303438 }304439 305-func TestSolvedGapBackgroundUsesSolvedColor(t *testing.T) {306- conflicts := validateGridState(grid{{2, 2}}).conflicts440+func TestCursorRegionInfoViewNumberedCell(t *testing.T) {307441 m := Model{308- width: 2,309- height: 1,310- grid: grid{{2, 2}},311- conflicts: conflicts,312- solved: true,442+ width: 3,443+ height: 3,444+ grid: grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}},445+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}),446+ conflicts: validateGridState(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}).conflicts,447+ cursor: pointCursor(0, 0),313448 }314- got := verticalGapBackground(m, nil, nil, 1, 0)315- if got == nil {316- t.Fatal("expected solved gap background")449+450+ if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 2/2"; got != want {451+ t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)317452 }318- if !sameColor(got, theme.Current().SuccessBG) {319- t.Fatal("expected solved gap background to use solved color")453+}454+455+func TestCursorRegionInfoViewEmptyRegion(t *testing.T) {456+ m := Model{457+ width: 3,458+ height: 3,459+ grid: grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}},460+ provided: newProvidedMask(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}),461+ conflicts: validateGridState(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}).conflicts,462+ cursor: pointCursor(2, 1),463+ }464+465+ if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 5/-"; got != want {466+ t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)467+ }468+}469+470+func TestViewKeepsStableHeightAcrossCursorRegionInfoChanges(t *testing.T) {471+ baseGrid := grid{472+ {2, 2, 1},473+ {3, 0, 0},474+ {0, 0, 1},475+ }476+ m := Model{477+ width: 3,478+ height: 3,479+ grid: baseGrid,480+ provided: newProvidedMask(baseGrid),481+ conflicts: validateGridState(baseGrid).conflicts,482+ modeTitle: "Test",483+ maxCellValue: 3,484+ }485+486+ numbered := m487+ numbered.cursor = pointCursor(0, 0)488+489+ empty := m490+ empty.cursor = pointCursor(2, 1)491+492+ if got, want := lipgloss.Height(empty.View()), lipgloss.Height(numbered.View()); got != want {493+ t.Fatalf("height(empty) = %d, want %d", got, want)320494 }321495 }322496
M fillomino/generator.go +2 -4
M fillomino/help.md +1 -1
M fillomino/solver.go +2 -2
M fillomino/style.go +117 -174
@@ -121,104 +121,80 @@ func gridView(m Model) string {
121121 }122122 },123123 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {124- for i := 0; i < bridge.Count; i++ {125- cell := bridge.Cells[i]126- if m.conflicts[cell.Y][cell.X] {127- return game.ConflictBG()128- }129- }130- return nil124+ return bridgeFill(m, renderState, bridge)131125 },132126 })133127 }134128 135-func buildRenderGridState(m Model) renderGridState {136- height := len(m.grid)137- if height == 0 {138- return renderGridState{}139- }140- width := len(m.grid[0])141- zones := make([][]int, height)142- for y := range height {143- zones[y] = make([]int, width)129+func bridgeFill(m Model, renderState renderGridState, bridge game.DynamicGridBridge) color.Color {130+ for i := 0; i < bridge.Count; i++ {131+ cell := bridge.Cells[i]132+ if m.conflicts[cell.Y][cell.X] {133+ return game.ConflictBG()134+ }144135 }145136 146- visited := make([][]bool, height)147- for y := range height {148- visited[y] = make([]bool, width)137+ if m.solved {138+ return nil149139 }150140 151- palette := theme.Current()152- colors := palette.ThemeColors()153- completed := make(map[int]color.Color)154- nextZone := 0155- activeZone := -1156- emptyZone := nextZone157- nextZone++158-159- for y := range height {160- for x := range width {161- if m.grid[y][x] == 0 {162- zones[y][x] = emptyZone163- continue164- }165- if visited[y][x] {166- continue167- }168-169- comp := buildComponent(m.grid, point{x: x, y: y}, visited)170- zone := nextZone171- nextZone++172- for _, cell := range comp.cells {173- zones[cell.y][cell.x] = zone174- }175- if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {176- completed[zone] = completedRegionColor(comp, colors, palette.Surface)177- }141+ if bridge.Uniform {142+ switch {143+ case renderState.completed[bridge.Zone] != nil:144+ return nil145+ case renderState.activeZone >= 0 && bridge.Zone == renderState.activeZone:146+ return nil178147 }179148 }180149 181- if m.cursor.Y >= 0 && m.cursor.Y < height && m.cursor.X >= 0 && m.cursor.X < width && m.grid[m.cursor.Y][m.cursor.X] != 0 {182- activeZone = zones[m.cursor.Y][m.cursor.X]150+ if bridge.Count > 0 && !bridgeTouchesBorder(renderState, bridge, m.width, m.height) {151+ return nil183152 }184153 185- return renderGridState{186- zones: zones,187- activeZone: activeZone,188- completed: completed,154+ if game.DynamicGridBridgeOnCrosshairAxis(m.cursor, bridge) {155+ return theme.Current().Surface156+ }157+158+ return nil159+}160+161+func bridgeTouchesBorder(renderState renderGridState, bridge game.DynamicGridBridge, width, height int) bool {162+ switch bridge.Kind {163+ case game.DynamicGridBridgeVertical:164+ return zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y) != ' ' ||165+ zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y+1) != ' '166+ case game.DynamicGridBridgeHorizontal:167+ return zoneJunctionRune(renderState.zones, width, height, bridge.X, bridge.Y) != ' ' ||168+ zoneJunctionRune(renderState.zones, width, height, bridge.X+1, bridge.Y) != ' '169+ default:170+ return false189171 }190172 }191173 192-func horizontalEdge(g grid, x, y int) bool {174+func zoneHorizontalEdge(zones [][]int, height, x, y int) bool {193175 switch {194- case y <= 0, y >= len(g):176+ case y <= 0, y >= height:195177 return true196178 default:197- top := point{x: x, y: y - 1}198- bottom := point{x: x, y: y}199- return !sameRegion(g, top, bottom)179+ return zones[y-1][x] != zones[y][x]200180 }201181 }202182 203-func hasVerticalEdge(g grid, x, y int) bool {204- width := len(g[0])183+func zoneVerticalEdge(zones [][]int, width, height, x, y int) bool {184+ _ = height205185 switch {206186 case x <= 0, x >= width:207187 return true208188 default:209- left := point{x: x - 1, y: y}210- right := point{x: x, y: y}211- return !sameRegion(g, left, right)189+ return zones[y][x-1] != zones[y][x]212190 }213191 }214192 215-func junctionRune(g grid, x, y int) rune {216- height := len(g)217- width := len(g[0])218- north := y > 0 && hasVerticalEdge(g, x, y-1)219- south := y < height && hasVerticalEdge(g, x, y)220- west := x > 0 && horizontalEdge(g, x-1, y)221- east := x < width && horizontalEdge(g, x, y)193+func zoneJunctionRune(zones [][]int, width, height, x, y int) rune {194+ north := y > 0 && zoneVerticalEdge(zones, width, height, x, y-1)195+ south := y < height && zoneVerticalEdge(zones, width, height, x, y)196+ west := x > 0 && zoneHorizontalEdge(zones, height, x-1, y)197+ east := x < width && zoneHorizontalEdge(zones, height, x, y)222198 223199 switch {224200 case north && south && west && east:
@@ -248,102 +224,17 @@ func junctionRune(g grid, x, y int) rune {
248224 }249225 }250226 251-func verticalGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {252- if hasVerticalEdge(m.grid, x, y) || x <= 0 || x >= m.width {253- return nil254- }255- left := point{x: x - 1, y: y}256- right := point{x: x, y: y}257- return gapBackground(m, regionSet, completed, left, right)258-}259-260-func horizontalGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {261- if horizontalEdge(m.grid, x, y) || y <= 0 || y >= m.height {262- return nil263- }264- top := point{x: x, y: y - 1}265- bottom := point{x: x, y: y}266- return gapBackground(m, regionSet, completed, top, bottom)267-}268-269-func junctionGapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, x, y int) color.Color {270- if junctionRune(m.grid, x, y) != ' ' {271- return nil272- }273-274- cells := make([]point, 0, 4)275- if x > 0 && y > 0 {276- cells = append(cells, point{x: x - 1, y: y - 1})277- }278- if x < m.width && y > 0 {279- cells = append(cells, point{x: x, y: y - 1})280- }281- if x > 0 && y < m.height {282- cells = append(cells, point{x: x - 1, y: y})283- }284- if x < m.width && y < m.height {285- cells = append(cells, point{x: x, y: y})286- }287- if len(cells) != 4 {288- return nil289- }290- for i := 1; i < len(cells); i++ {291- if !sameRegion(m.grid, cells[0], cells[i]) {292- return nil293- }294- }295- return gapBackground(m, regionSet, completed, cells...)296-}297-298-func gapBackground(m Model, regionSet map[point]struct{}, completed map[point]color.Color, cells ...point) color.Color {299- p := theme.Current()300- if len(cells) == 0 {301- return nil302- }303- if anyCellConflict(m.conflicts, cells...) {304- return game.ConflictBG()305- }306- if m.solved {307- return p.SuccessBG308- }309- if bg := completed[cells[0]]; bg != nil {310- for _, cell := range cells[1:] {311- if completed[cell] == nil {312- return nil313- }314- }315- return bg316- }317- for _, cell := range cells {318- if _, ok := regionSet[cell]; !ok {319- return nil320- }321- }322- if len(regionSet) == 0 {323- return nil227+func buildRenderGridState(m Model) renderGridState {228+ height := len(m.grid)229+ if height == 0 {230+ return renderGridState{}324231 }325- return p.HighlightBG326-}327-328-func anyCellConflict(conflicts [][]bool, cells ...point) bool {329- for _, cell := range cells {330- if conflicts[cell.y][cell.x] {331- return true332- }232+ width := len(m.grid[0])233+ zones := make([][]int, height)234+ for y := range height {235+ zones[y] = make([]int, width)333236 }334- return false335-}336237 337-func sameRegion(g grid, a, b point) bool {338- return g[a.y][a.x] != 0 && g[a.y][a.x] == g[b.y][b.x]339-}340-341-func completedRegionBackgrounds(g grid, conflicts [][]bool) map[point]color.Color {342- height := len(g)343- if height == 0 {344- return nil345- }346- width := len(g[0])347238 visited := make([][]bool, height)348239 for y := range height {349240 visited[y] = make([]bool, width)
@@ -351,30 +242,43 @@ func completedRegionBackgrounds(g grid, conflicts [][]bool) map[point]color.Colo
351242 352243 palette := theme.Current()353244 colors := palette.ThemeColors()354- if len(colors) == 0 {355- return nil356- }245+ completed := make(map[int]color.Color)246+ nextZone := 0247+ activeZone := -1248+ emptyZone := nextZone249+ nextZone++357250 358- backgrounds := make(map[point]color.Color)359251 for y := range height {360252 for x := range width {361- if g[y][x] == 0 || visited[y][x] {253+ if m.grid[y][x] == 0 {254+ zones[y][x] = emptyZone362255 continue363256 }364-365- comp := buildComponent(g, point{x: x, y: y}, visited)366- if len(comp.cells) != comp.value || componentHasConflict(comp, conflicts) {257+ if visited[y][x] {367258 continue368259 }369260 370- bg := completedRegionColor(comp, colors, palette.Surface)261+ comp := buildComponent(m.grid, point{x: x, y: y}, visited)262+ zone := nextZone263+ nextZone++371264 for _, cell := range comp.cells {372- backgrounds[cell] = bg265+ zones[cell.y][cell.x] = zone266+ }267+ if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {268+ completed[zone] = completedRegionColor(comp, colors, palette.Surface)373269 }374270 }375271 }376272 377- return backgrounds273+ if m.cursor.Y >= 0 && m.cursor.Y < height && m.cursor.X >= 0 && m.cursor.X < width && m.grid[m.cursor.Y][m.cursor.X] != 0 {274+ activeZone = zones[m.cursor.Y][m.cursor.X]275+ }276+277+ return renderGridState{278+ zones: zones,279+ activeZone: activeZone,280+ completed: completed,281+ }378282 }379283 380284 func componentHasConflict(comp component, conflicts [][]bool) bool {
@@ -392,9 +296,48 @@ func completedRegionColor(comp component, colors []color.Color, base color.Color
392296 return theme.Blend(base, colors[index], 0.52)393297 }394298 299+func cursorRegionInfoStyle() lipgloss.Style {300+ return lipgloss.NewStyle().Foreground(theme.Current().Info)301+}302+303+func cursorRegionInfoView(m Model) string {304+ current, target := cursorRegionInfoData(m)305+ return cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(current) + "/" + target)306+}307+308+func cursorRegionInfoVariants(m Model) []string {309+ area := m.width * m.height310+ maxTarget := m.maxCellValue311+ maxTarget = max(maxTarget, 1)312+313+ return []string{314+ cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/-"),315+ cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/" + strconv.Itoa(maxTarget)),316+ }317+}318+319+func cursorRegionInfoData(m Model) (current int, target string) {320+ if len(m.grid) == 0 || len(m.grid[0]) == 0 {321+ return 0, "-"322+ }323+324+ cursor := point{x: m.cursor.X, y: m.cursor.Y}325+ value := m.grid[cursor.y][cursor.x]326+ visited := make([][]bool, len(m.grid))327+ for y := range len(m.grid) {328+ visited[y] = make([]bool, len(m.grid[y]))329+ }330+331+ comp := buildComponent(m.grid, cursor, visited)332+ if value == 0 {333+ return len(comp.cells), "-"334+ }335+ return len(comp.cells), strconv.Itoa(value)336+}337+395338 func statusBarView(showFullHelp bool) string {396339 if showFullHelp {397- return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move ctrl+n: menu ctrl+r: reset ctrl+h: help")340+ return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move esc: menu ctrl+r: reset ctrl+h: help")398341 }399342 return game.StatusBarStyle().Render("1-9: place bkspc: clear")400343 }
M game/border.go +32 -15
@@ -2,6 +2,7 @@ package game
22 33 import (44 "image/color"5+ "strings"56 67 "charm.land/lipgloss/v2"78 "github.com/FelineStateMachine/puzzletea/theme"
@@ -17,6 +18,26 @@ type GridBorderColors struct {
1718 SolvedBG color.Color1819 }1920 21+func borderColors(colors GridBorderColors, solved bool, bg color.Color) (color.Color, color.Color) {22+ fg := colors.BorderFG23+ background := bg24+25+ if solved {26+ fg = colors.SolvedBorderFG27+ }28+29+ if background == nil {30+ background = colors.BackgroundBG31+ if solved {32+ background = colors.SolvedBG33+ }34+ return fg, background35+ }36+37+ // Tinted bridge/crosshair backgrounds need a contrast-aware foreground.38+ return theme.TextOnBG(background), background39+}40+2041 // DefaultBorderColors returns border colors from the active theme.2142 func DefaultBorderColors() GridBorderColors {2243 p := theme.Current()
@@ -31,13 +52,12 @@ func DefaultBorderColors() GridBorderColors {
3152 3253 // BorderChar renders a single border character with optional crosshair highlighting.3354 func BorderChar(ch string, colors GridBorderColors, solved, highlight bool) string {34- s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)35- if solved {36- s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)37- } else if highlight {38- s = s.Background(colors.CrosshairBG)55+ bg := color.Color(nil)56+ if highlight && !solved {57+ bg = colors.CrosshairBG3958 }40- return s.Render(ch)59+ fg, bg := borderColors(colors, solved, bg)60+ return lipgloss.NewStyle().Foreground(fg).Background(bg).Render(ch)4161 }4262 4363 // HBorderRow builds a horizontal border row (top or bottom) with per-column
@@ -45,18 +65,15 @@ func BorderChar(ch string, colors GridBorderColors, solved, highlight bool) stri
4565 func HBorderRow(w, cursorX, cellWidth int, left, right string, colors GridBorderColors, solved bool) string {4666 var parts []string4767 parts = append(parts, BorderChar(left, colors, solved, false))48- segment := ""49- for range cellWidth {50- segment += "─"51- }68+ segment := strings.Repeat("─", cellWidth)5269 for x := range w {5370 highlight := !solved && x == cursorX54- s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)55- if solved {56- s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)57- } else if highlight {58- s = s.Background(colors.CrosshairBG)71+ bg := color.Color(nil)72+ if highlight {73+ bg = colors.CrosshairBG5974 }75+ fg, bg := borderColors(colors, solved, bg)76+ s := lipgloss.NewStyle().Foreground(fg).Background(bg)6077 parts = append(parts, s.Render(segment))6178 }6279 parts = append(parts, BorderChar(right, colors, solved, false))
M game/dynamic_grid.go +310 -78
@@ -10,15 +10,12 @@ import (
1010 1111 const DynamicGridCellWidth = 31212 13-const (14- dynamicGridCellStepX = DynamicGridCellWidth + 115- dynamicGridCellStepY = 216-)17-1813 type DynamicGridMetrics struct {19- Width int20- Height int21- CellWidth int14+ Width int15+ Height int16+ CellWidth int17+ VerticalBridgeWidth func(x int) int18+ HorizontalBridgeHeight func(y int) int2219 }2320 2421 type DynamicGridBridgeKind int
@@ -40,26 +37,50 @@ type DynamicGridBridge struct {
4037 }4138 4239 type DynamicGridSpec struct {43- Width int44- Height int45- Solved bool46- Colors *GridBorderColors47- Cell func(x, y int) string48- ZoneAt func(x, y int) int49- ZoneFill func(zone int) color.Color50- BridgeFill func(bridge DynamicGridBridge) color.Color40+ Width int41+ Height int42+ CellWidth int43+ Solved bool44+ Colors *GridBorderColors45+ Cell func(x, y int) string46+ ZoneAt func(x, y int) int47+ HasVerticalEdge func(x, y int) bool48+ HasHorizontalEdge func(x, y int) bool49+ VerticalBridgeWidth func(x int) int50+ HorizontalBridgeHeight func(y int) int51+ ZoneFill func(zone int) color.Color52+ BridgeFill func(bridge DynamicGridBridge) color.Color53+ BridgeForeground func(bridge DynamicGridBridge) color.Color54+ BridgeBold func(bridge DynamicGridBridge) bool55+ VerticalBridgeText func(x, y int) string56+ HorizontalBridgeText func(x, y int) string5157 }5258 5359 func RenderDynamicGrid(spec DynamicGridSpec) string {54- rows := make([]string, 0, spec.Height*dynamicGridCellStepY+1)55- rows = append(rows, dynamicGridBoundaryRow(spec, 0))56- for y := range spec.Height {60+ rows := make([]string, 0, spec.Height*2+1)61+ for y := 0; y <= spec.Height; y++ {62+ rowHeight := dynamicGridHorizontalBridgeHeight(spec, y)63+ if rowHeight > 0 {64+ row := dynamicGridBoundaryRow(spec, y)65+ for range rowHeight {66+ rows = append(rows, row)67+ }68+ }69+ if y == spec.Height {70+ continue71+ }5772 rows = append(rows, dynamicGridContentRow(spec, y))58- rows = append(rows, dynamicGridBoundaryRow(spec, y+1))5973 }6074 return lipgloss.JoinVertical(lipgloss.Left, rows...)6175 }6276 77+func dynamicGridCellWidth(spec DynamicGridSpec) int {78+ if spec.CellWidth > 0 {79+ return spec.CellWidth80+ }81+ return DynamicGridCellWidth82+}83+6384 func DynamicGridOrigin(termW, termH int, fullView, title, grid string) (x, y int) {6485 viewWidth := lipgloss.Width(fullView)6586 viewHeight := lipgloss.Height(fullView)
@@ -100,21 +121,30 @@ func DynamicGridScreenToCell(
100121 return 0, 0, false101122 }102123 103- maxX := (metrics.Width-1)*(cellWidth+1) + (cellWidth - 1)104- maxY := (metrics.Height - 1) * dynamicGridCellStepY105- if lx > maxX || ly > maxY {124+ col, ok = dynamicGridLocateAxis(125+ lx,126+ metrics.Width,127+ cellWidth,128+ includeSeparators,129+ func(x int) int {130+ return dynamicGridMetricVerticalBridgeWidth(metrics, x)131+ },132+ )133+ if !ok {106134 return 0, 0, false107135 }108136 109- if includeSeparators {110- col = min((lx+cellWidth/2)/(cellWidth+1), metrics.Width-1)111- row = min((ly+1)/dynamicGridCellStepY, metrics.Height-1)112- } else {113- col = lx / (cellWidth + 1)114- row = ly / dynamicGridCellStepY115- if lx%(cellWidth+1) >= cellWidth || ly%dynamicGridCellStepY != 0 {116- return 0, 0, false117- }137+ row, ok = dynamicGridLocateAxis(138+ ly,139+ metrics.Height,140+ 1,141+ includeSeparators,142+ func(y int) int {143+ return dynamicGridMetricHorizontalBridgeHeight(metrics, y)144+ },145+ )146+ if !ok {147+ return 0, 0, false118148 }119149 120150 if col < 0 || col >= metrics.Width || row < 0 || row >= metrics.Height {
@@ -123,58 +153,60 @@ func DynamicGridScreenToCell(
123153 return col, row, true124154 }125155 156+// DynamicGridBridgeOnCrosshairAxis reports whether a bridge should be tinted157+// by a row/column crosshair for the given cursor position.158+func DynamicGridBridgeOnCrosshairAxis(cursor Cursor, bridge DynamicGridBridge) bool {159+ switch bridge.Kind {160+ case DynamicGridBridgeVertical:161+ if bridge.Count == 0 {162+ return bridge.Y == cursor.Y163+ }164+ for i := 0; i < bridge.Count; i++ {165+ if bridge.Cells[i].Y == cursor.Y {166+ return true167+ }168+ }169+ case DynamicGridBridgeHorizontal:170+ if bridge.Count == 0 {171+ return bridge.X == cursor.X172+ }173+ for i := 0; i < bridge.Count; i++ {174+ if bridge.Cells[i].X == cursor.X {175+ return true176+ }177+ }178+ }179+ return false180+}181+126182 func dynamicGridContentRow(spec DynamicGridSpec, y int) string {127183 var b strings.Builder128184 for x := range spec.Width {129185 bridge := dynamicGridVerticalBridge(spec, x, y)130- b.WriteString(dynamicGridRenderBorderChar(131- verticalEdgeRune(dynamicGridHasVerticalEdge(spec, x, y)),132- dynamicGridColors(spec),133- spec.Solved,134- dynamicGridBridgeBackground(spec, bridge),135- ))186+ b.WriteString(dynamicGridRenderVerticalBridge(spec, x, y, bridge))136187 if spec.Cell != nil {137188 b.WriteString(spec.Cell(x, y))138189 }139190 }140191 141192 bridge := dynamicGridVerticalBridge(spec, spec.Width, y)142- b.WriteString(dynamicGridRenderBorderChar(143- verticalEdgeRune(dynamicGridHasVerticalEdge(spec, spec.Width, y)),144- dynamicGridColors(spec),145- spec.Solved,146- dynamicGridBridgeBackground(spec, bridge),147- ))193+ b.WriteString(dynamicGridRenderVerticalBridge(spec, spec.Width, y, bridge))148194 return b.String()149195 }150196 151197 func dynamicGridBoundaryRow(spec DynamicGridSpec, y int) string {152198 var b strings.Builder153199 for x := 0; x <= spec.Width; x++ {154- runeValue := dynamicGridJunctionRune(spec, x, y)155- b.WriteString(dynamicGridRenderBorderChar(156- runeValue,157- dynamicGridColors(spec),158- spec.Solved,159- dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),160- ))200+ junctionWidth := dynamicGridVerticalBridgeWidth(spec, x)201+ if junctionWidth > 0 {202+ runeValue := dynamicGridJunctionRune(spec, x, y)203+ b.WriteString(dynamicGridRenderJunction(spec, x, y, junctionWidth, runeValue))204+ }161205 if x == spec.Width {162206 continue163207 }164-165- ch := ' '166- if dynamicGridHasHorizontalEdge(spec, x, y) {167- ch = '─'168- }169- b.WriteString(strings.Repeat(170- dynamicGridRenderBorderChar(171- ch,172- dynamicGridColors(spec),173- spec.Solved,174- dynamicGridBridgeBackground(spec, dynamicGridHorizontalBridge(spec, x, y)),175- ),176- DynamicGridCellWidth,177- ))208+ bridge := dynamicGridHorizontalBridge(spec, x, y)209+ b.WriteString(dynamicGridRenderHorizontalBridge(spec, x, y, bridge))178210 }179211 return b.String()180212 }
@@ -187,23 +219,125 @@ func dynamicGridColors(spec DynamicGridSpec) GridBorderColors {
187219 }188220 189221 func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool, bg color.Color) string {190- fg := colors.BorderFG191- if solved {192- fg = colors.SolvedBorderFG193- }194- if bg == nil {195- bg = colors.BackgroundBG196- if solved {197- bg = colors.SolvedBG198- }199- }222+ fg, bg := borderColors(colors, solved, bg)200223 return lipgloss.NewStyle().201224 Foreground(fg).202225 Background(bg).203226 Render(string(ch))204227 }205228 229+func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {230+ bridgeWidth := dynamicGridVerticalBridgeWidth(spec, x)231+ if bridgeWidth <= 0 {232+ return ""233+ }234+235+ if dynamicGridHasVerticalEdge(spec, x, y) {236+ return strings.Repeat(237+ dynamicGridRenderBorderChar(238+ verticalEdgeRune(true),239+ dynamicGridColors(spec),240+ spec.Solved,241+ dynamicGridBridgeBackground(spec, bridge),242+ ),243+ bridgeWidth,244+ )245+ }246+247+ if spec.VerticalBridgeText != nil {248+ if text := strings.TrimSpace(spec.VerticalBridgeText(x, y)); text != "" {249+ return dynamicGridRenderBridgeText(250+ text,251+ bridgeWidth,252+ dynamicGridColors(spec),253+ spec.Solved,254+ dynamicGridBridgeBackground(spec, bridge),255+ dynamicGridBridgeForeground(spec, bridge),256+ dynamicGridBridgeBold(spec, bridge),257+ )258+ }259+ }260+261+ return strings.Repeat(262+ dynamicGridRenderBorderChar(263+ verticalEdgeRune(false),264+ dynamicGridColors(spec),265+ spec.Solved,266+ dynamicGridBridgeBackground(spec, bridge),267+ ),268+ bridgeWidth,269+ )270+}271+272+func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {273+ cellWidth := dynamicGridCellWidth(spec)274+ if dynamicGridHasHorizontalEdge(spec, x, y) {275+ return strings.Repeat(276+ dynamicGridRenderBorderChar(277+ '─',278+ dynamicGridColors(spec),279+ spec.Solved,280+ dynamicGridBridgeBackground(spec, bridge),281+ ),282+ cellWidth,283+ )284+ }285+286+ if spec.HorizontalBridgeText != nil {287+ if text := strings.TrimSpace(spec.HorizontalBridgeText(x, y)); text != "" {288+ return dynamicGridRenderBridgeText(289+ text,290+ cellWidth,291+ dynamicGridColors(spec),292+ spec.Solved,293+ dynamicGridBridgeBackground(spec, bridge),294+ dynamicGridBridgeForeground(spec, bridge),295+ dynamicGridBridgeBold(spec, bridge),296+ )297+ }298+ }299+300+ return strings.Repeat(301+ dynamicGridRenderBorderChar(302+ ' ',303+ dynamicGridColors(spec),304+ spec.Solved,305+ dynamicGridBridgeBackground(spec, bridge),306+ ),307+ cellWidth,308+ )309+}310+311+func dynamicGridRenderBridgeText(312+ text string,313+ width int,314+ colors GridBorderColors,315+ solved bool,316+ bg color.Color,317+ fgOverride color.Color,318+ bold bool,319+) string {320+ fg, bg := borderColors(colors, solved, bg)321+ if fgOverride != nil {322+ fg = fgOverride323+ }324+325+ style := lipgloss.NewStyle().326+ Width(width).327+ AlignHorizontal(lipgloss.Center).328+ Foreground(fg).329+ Background(bg)330+ if bold {331+ style = style.Bold(true)332+ }333+ return style.Render(text)334+}335+206336 func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {337+ if spec.HasHorizontalEdge != nil {338+ return spec.HasHorizontalEdge(x, y)339+ }340+207341 switch {208342 case y <= 0, y >= spec.Height:209343 return true
@@ -213,6 +347,10 @@ func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {
213347 }214348 215349 func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {350+ if spec.HasVerticalEdge != nil {351+ return spec.HasVerticalEdge(x, y)352+ }353+216354 switch {217355 case x <= 0, x >= spec.Width:218356 return true
@@ -221,6 +359,22 @@ func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {
221359 }222360 }223361 362+func dynamicGridRenderJunction(spec DynamicGridSpec, x, y, width int, runeValue rune) string {363+ if width <= 0 {364+ return ""365+ }366+367+ return dynamicGridRenderBridgeText(368+ string(runeValue),369+ width,370+ dynamicGridColors(spec),371+ spec.Solved,372+ dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),373+ nil,374+ false,375+ )376+}377+224378 func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {225379 north := y > 0 && dynamicGridHasVerticalEdge(spec, x, y-1)226380 south := y < spec.Height && dynamicGridHasVerticalEdge(spec, x, y)
@@ -256,9 +410,6 @@ func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {
256410 }257411 258412 func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {259- if bridge.Count == 0 {260- return nil261- }262413 if spec.BridgeFill != nil {263414 if bg := spec.BridgeFill(bridge); bg != nil {264415 return bg
@@ -270,6 +421,59 @@ func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge)
270421 return nil271422 }272423 424+func dynamicGridBridgeForeground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {425+ if spec.BridgeForeground != nil {426+ return spec.BridgeForeground(bridge)427+ }428+ return nil429+}430+431+func dynamicGridBridgeBold(spec DynamicGridSpec, bridge DynamicGridBridge) bool {432+ if spec.BridgeBold != nil {433+ return spec.BridgeBold(bridge)434+ }435+ return false436+}437+438+func dynamicGridLocateAxis(439+ offset, count, cellSpan int,440+ includeSeparators bool,441+ bridgeSize func(index int) int,442+) (index int, ok bool) {443+ if count <= 0 || cellSpan <= 0 {444+ return 0, false445+ }446+447+ position := 0448+ for i := range count {449+ cellEnd := position + cellSpan450+ if offset < cellEnd {451+ return i, true452+ }453+454+ position = cellEnd455+ if i == count-1 {456+ break457+ }458+459+ size := max(bridgeSize(i+1), 0)460+ if size <= 0 {461+ continue462+ }463+464+ bridgeEnd := position + size465+ if offset < bridgeEnd {466+ if !includeSeparators {467+ return 0, false468+ }469+ return i + 1, true470+ }471+ position = bridgeEnd472+ }473+474+ return 0, false475+}476+273477 func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {274478 if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {275479 return DynamicGridBridge{
@@ -366,6 +570,34 @@ func dynamicGridZoneAt(spec DynamicGridSpec, x, y int) int {
366570 return spec.ZoneAt(x, y)367571 }368572 573+func dynamicGridVerticalBridgeWidth(spec DynamicGridSpec, x int) int {574+ if spec.VerticalBridgeWidth == nil {575+ return 1576+ }577+ return max(spec.VerticalBridgeWidth(x), 0)578+}579+580+func dynamicGridHorizontalBridgeHeight(spec DynamicGridSpec, y int) int {581+ if spec.HorizontalBridgeHeight == nil {582+ return 1583+ }584+ return max(spec.HorizontalBridgeHeight(y), 0)585+}586+587+func dynamicGridMetricVerticalBridgeWidth(metrics DynamicGridMetrics, x int) int {588+ if metrics.VerticalBridgeWidth == nil {589+ return 1590+ }591+ return max(metrics.VerticalBridgeWidth(x), 0)592+}593+594+func dynamicGridMetricHorizontalBridgeHeight(metrics DynamicGridMetrics, y int) int {595+ if metrics.HorizontalBridgeHeight == nil {596+ return 1597+ }598+ return max(metrics.HorizontalBridgeHeight(y), 0)599+}600+369601 func verticalEdgeRune(hasEdge bool) rune {370602 if hasEdge {371603 return '│'
M game/dynamic_grid_test.go +326 -0
@@ -1,6 +1,8 @@
11 package game22 33 import (4+ "image"5+ "image/color"46 "strings"57 "testing"68
@@ -79,6 +81,124 @@ func TestRenderDynamicGridUsesUniformRows(t *testing.T) {
7981 }8082 }8183 84+func TestRenderDynamicGridSupportsCustomCellWidth(t *testing.T) {85+ view := RenderDynamicGrid(DynamicGridSpec{86+ Width: 2,87+ Height: 1,88+ CellWidth: 5,89+ Cell: func(_, _ int) string {90+ return " "91+ },92+ ZoneAt: func(_, _ int) int {93+ return 094+ },95+ VerticalBridgeText: func(x, y int) string {96+ if x == 1 && y == 0 {97+ return "x"98+ }99+ return ""100+ },101+ })102+103+ lines := strings.Split(ansi.Strip(view), "\n")104+ if got := len([]rune(lines[0])); got != 13 {105+ t.Fatalf("top row width = %d, want %d", got, 13)106+ }107+ if !strings.Contains(lines[1], "x") {108+ t.Fatalf("content row = %q, want custom-width bridge text", lines[1])109+ }110+}111+112+func TestRenderDynamicGridCollapsesSparseBridges(t *testing.T) {113+ view := RenderDynamicGrid(DynamicGridSpec{114+ Width: 2,115+ Height: 2,116+ Cell: func(_, _ int) string {117+ return " "118+ },119+ ZoneAt: func(_, _ int) int {120+ return 0121+ },122+ HasVerticalEdge: func(x, _ int) bool {123+ return x <= 0 || x >= 2124+ },125+ HasHorizontalEdge: func(_, y int) bool {126+ return y <= 0 || y >= 2127+ },128+ VerticalBridgeWidth: func(x int) int {129+ if x <= 0 || x >= 2 {130+ return 1131+ }132+ return 0133+ },134+ HorizontalBridgeHeight: func(y int) int {135+ if y <= 0 || y >= 2 {136+ return 1137+ }138+ return 0139+ },140+ })141+142+ lines := strings.Split(ansi.Strip(view), "\n")143+ if got, want := len(lines), 4; got != want {144+ t.Fatalf("line count = %d, want %d", got, want)145+ }146+ if got, want := lines[0], "┌──────┐"; got != want {147+ t.Fatalf("top row = %q, want %q", got, want)148+ }149+ if got := strings.Count(lines[1], "│"); got != 2 {150+ t.Fatalf("content row border count = %d, want 2", got)151+ }152+}153+154+func TestRenderDynamicGridEdgeOverridesSuppressInteriorBorders(t *testing.T) {155+ view := RenderDynamicGrid(DynamicGridSpec{156+ Width: 2,157+ Height: 2,158+ Cell: func(_, _ int) string {159+ return " "160+ },161+ ZoneAt: func(x, y int) int {162+ return y*10 + x163+ },164+ HasVerticalEdge: func(x, _ int) bool {165+ return x <= 0 || x >= 2166+ },167+ HasHorizontalEdge: func(_, y int) bool {168+ return y <= 0 || y >= 2169+ },170+ })171+172+ lines := strings.Split(ansi.Strip(view), "\n")173+ if got := []rune(lines[1])[4]; got != ' ' {174+ t.Fatalf("interior vertical bridge rune = %q, want %q", got, ' ')175+ }176+ if got := []rune(lines[2])[4]; got != ' ' {177+ t.Fatalf("interior junction rune = %q, want %q", got, ' ')178+ }179+}180+181+func TestRenderDynamicGridDefaultsToZoneEdgesWithoutOverrides(t *testing.T) {182+ view := RenderDynamicGrid(DynamicGridSpec{183+ Width: 2,184+ Height: 2,185+ Cell: func(_, _ int) string {186+ return " "187+ },188+ ZoneAt: func(x, y int) int {189+ return y*10 + x190+ },191+ })192+193+ lines := strings.Split(ansi.Strip(view), "\n")194+ if got := []rune(lines[1])[4]; got != '│' {195+ t.Fatalf("interior vertical bridge rune = %q, want %q", got, '│')196+ }197+ if got := []rune(lines[2])[4]; got != '┼' {198+ t.Fatalf("interior junction rune = %q, want %q", got, '┼')199+ }200+}201+82202 func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {83203 got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), true, nil)84204 want := lipgloss.NewStyle().
@@ -90,6 +210,30 @@ func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {
90210 }91211 }92212 213+func TestDynamicGridRenderBorderCharUsesTextOnTintedBackground(t *testing.T) {214+ bg := theme.Current().Surface215+ got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), false, bg)216+ want := lipgloss.NewStyle().217+ Foreground(theme.TextOnBG(bg)).218+ Background(bg).219+ Render("│")220+ if got != want {221+ t.Fatal("expected tinted border char to use contrast-aware foreground")222+ }223+}224+225+func TestBorderCharHighlightUsesTextOnCrosshairBackground(t *testing.T) {226+ colors := DefaultBorderColors()227+ got := BorderChar("│", colors, false, true)228+ want := lipgloss.NewStyle().229+ Foreground(theme.TextOnBG(colors.CrosshairBG)).230+ Background(colors.CrosshairBG).231+ Render("│")232+ if got != want {233+ t.Fatal("expected highlighted border char to use contrast-aware foreground")234+ }235+}236+93237 func TestDynamicGridScreenToCell(t *testing.T) {94238 metrics := DynamicGridMetrics{Width: 3, Height: 3, CellWidth: DynamicGridCellWidth}95239 originX, originY := 10, 20
@@ -130,6 +274,140 @@ func TestDynamicGridScreenToCell(t *testing.T) {
130274 }131275 }132276 277+func TestDynamicGridScreenToCellSupportsSparseBridges(t *testing.T) {278+ metrics := DynamicGridMetrics{279+ Width: 3,280+ Height: 3,281+ CellWidth: DynamicGridCellWidth,282+ VerticalBridgeWidth: func(x int) int {283+ if x == 2 {284+ return 1285+ }286+ return 0287+ },288+ HorizontalBridgeHeight: func(y int) int {289+ if y == 2 {290+ return 1291+ }292+ return 0293+ },294+ }295+ originX, originY := 10, 20296+297+ tests := []struct {298+ name string299+ screenX int300+ screenY int301+ includeSeparators bool302+ wantCol int303+ wantRow int304+ wantOK bool305+ }{306+ {"flush next cell", originX + DynamicGridCellWidth, originY, false, 1, 0, true},307+ {"major vertical separator rejected", originX + 2*DynamicGridCellWidth, originY, false, 0, 0, false},308+ {"major vertical separator snaps", originX + 2*DynamicGridCellWidth, originY, true, 2, 0, true},309+ {"major horizontal separator rejected", originX, originY + 2, false, 0, 0, false},310+ {"major horizontal separator snaps", originX, originY + 2, true, 0, 2, true},311+ {"outside right", originX + 3*DynamicGridCellWidth + 1, originY, false, 0, 0, false},312+ }313+314+ for _, tt := range tests {315+ t.Run(tt.name, func(t *testing.T) {316+ col, row, ok := DynamicGridScreenToCell(317+ metrics,318+ originX,319+ originY,320+ tt.screenX,321+ tt.screenY,322+ tt.includeSeparators,323+ )324+ if ok != tt.wantOK {325+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)326+ }327+ if ok && (col != tt.wantCol || row != tt.wantRow) {328+ t.Fatalf("cell = (%d,%d), want (%d,%d)", col, row, tt.wantCol, tt.wantRow)329+ }330+ })331+ }332+}333+334+func TestDynamicGridBridgeOnCrosshairAxis(t *testing.T) {335+ cursor := Cursor{X: 2, Y: 1}336+337+ tests := []struct {338+ name string339+ bridge DynamicGridBridge340+ want bool341+ }{342+ {343+ name: "vertical bridge matches cursor row via cells",344+ bridge: DynamicGridBridge{345+ Kind: DynamicGridBridgeVertical,346+ Cells: [4]image.Point{{X: 1, Y: 1}, {X: 2, Y: 1}},347+ Count: 2,348+ },349+ want: true,350+ },351+ {352+ name: "horizontal bridge matches cursor column via cells",353+ bridge: DynamicGridBridge{354+ Kind: DynamicGridBridgeHorizontal,355+ Cells: [4]image.Point{{X: 2, Y: 0}, {X: 2, Y: 1}},356+ Count: 2,357+ },358+ want: true,359+ },360+ {361+ name: "separator vertical bridge matches cursor row without cells",362+ bridge: DynamicGridBridge{363+ Kind: DynamicGridBridgeVertical,364+ Y: 1,365+ },366+ want: true,367+ },368+ {369+ name: "separator horizontal bridge matches cursor column without cells",370+ bridge: DynamicGridBridge{371+ Kind: DynamicGridBridgeHorizontal,372+ X: 2,373+ },374+ want: true,375+ },376+ {377+ name: "junction does not participate in axis highlighting",378+ bridge: DynamicGridBridge{379+ Kind: DynamicGridBridgeJunction,380+ Cells: [4]image.Point{{X: 2, Y: 1}},381+ Count: 1,382+ },383+ want: false,384+ },385+ }386+387+ for _, tt := range tests {388+ t.Run(tt.name, func(t *testing.T) {389+ if got := DynamicGridBridgeOnCrosshairAxis(cursor, tt.bridge); got != tt.want {390+ t.Fatalf("DynamicGridBridgeOnCrosshairAxis() = %v, want %v", got, tt.want)391+ }392+ })393+ }394+}395+396+func TestSameColor(t *testing.T) {397+ if !SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, color.NRGBA{R: 10, G: 20, B: 30, A: 40}) {398+ t.Fatal("expected identical colors to match")399+ }400+ if SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, color.NRGBA{R: 10, G: 20, B: 30, A: 41}) {401+ t.Fatal("expected different colors not to match")402+ }403+ if !SameColor(nil, nil) {404+ t.Fatal("expected nil colors to match")405+ }406+ if SameColor(color.NRGBA{R: 10, G: 20, B: 30, A: 40}, nil) {407+ t.Fatal("expected color and nil not to match")408+ }409+}410+133411 func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {134412 grid := RenderDynamicGrid(DynamicGridSpec{135413 Width: 3,
@@ -151,3 +429,51 @@ func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {
151429 t.Fatalf("origin changed from (%d,%d) to (%d,%d)", shortX, shortY, longX, longY)152430 }153431 }432+433+func TestRenderDynamicGridVerticalBridgeText(t *testing.T) {434+ view := RenderDynamicGrid(DynamicGridSpec{435+ Width: 2,436+ Height: 1,437+ Cell: func(_, _ int) string {438+ return " "439+ },440+ ZoneAt: func(_, _ int) int {441+ return 0442+ },443+ VerticalBridgeText: func(x, y int) string {444+ if x == 1 && y == 0 {445+ return "x"446+ }447+ return ""448+ },449+ })450+451+ lines := strings.Split(ansi.Strip(view), "\n")452+ if got := []rune(lines[1])[4]; got != 'x' {453+ t.Fatalf("vertical bridge rune = %q, want %q", got, 'x')454+ }455+}456+457+func TestRenderDynamicGridHorizontalBridgeText(t *testing.T) {458+ view := RenderDynamicGrid(DynamicGridSpec{459+ Width: 1,460+ Height: 2,461+ Cell: func(_, _ int) string {462+ return " "463+ },464+ ZoneAt: func(_, _ int) int {465+ return 0466+ },467+ HorizontalBridgeText: func(x, y int) string {468+ if x == 0 && y == 1 {469+ return "="470+ }471+ return ""472+ },473+ })474+475+ lines := strings.Split(ansi.Strip(view), "\n")476+ if got := []rune(lines[2])[2]; got != '=' {477+ t.Fatalf("horizontal bridge rune = %q, want %q", got, '=')478+ }479+}
M game/print_adapter.go +6 -49
@@ -1,64 +1,21 @@
11 package game22 3-import (4- "reflect"5- "strings"3+import "github.com/FelineStateMachine/puzzletea/pdfexport"64 7- "codeberg.org/go-pdf/fpdf"8-)9-10-type PrintAdapter interface {11- CanonicalGameType() string12- Aliases() []string13- BuildPDFPayload(save []byte) (any, error)14- RenderPDFBody(pdf *fpdf.Fpdf, payload any) error15-}16-17-var printAdapterRegistry = map[string]PrintAdapter{}5+type PrintAdapter = pdfexport.PrintAdapter186 197 func RegisterPrintAdapter(adapter PrintAdapter) {20- canonical := normalizeGameTypeToken(adapter.CanonicalGameType())21- if canonical == "" {22- return23- }24-25- printAdapterRegistry[canonical] = adapter26- for _, alias := range adapter.Aliases() {27- normalized := normalizeGameTypeToken(alias)28- if normalized == "" {29- continue30- }31- printAdapterRegistry[normalized] = adapter32- }8+ pdfexport.RegisterPrintAdapter(adapter)339 }3410 3511 func LookupPrintAdapter(gameType string) (PrintAdapter, bool) {36- adapter, ok := printAdapterRegistry[normalizeGameTypeToken(gameType)]37- return adapter, ok12+ return pdfexport.LookupPrintAdapter(gameType)3813 }3914 4015 func HasPrintAdapter(gameType string) bool {41- _, ok := LookupPrintAdapter(gameType)42- return ok16+ return pdfexport.HasPrintAdapter(gameType)4317 }4418 4519 func IsNilPrintPayload(payload any) bool {46- if payload == nil {47- return true48- }49-50- v := reflect.ValueOf(payload)51- switch v.Kind() {52- case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:53- return v.IsNil()54- default:55- return false56- }57-}58-59-func normalizeGameTypeToken(s string) string {60- s = strings.ToLower(strings.TrimSpace(s))61- s = strings.ReplaceAll(s, "-", " ")62- s = strings.ReplaceAll(s, "_", " ")63- return strings.Join(strings.Fields(s), " ")20+ return pdfexport.IsNilPrintPayload(payload)6421 }
M game/print_adapter_test.go +7 -15
@@ -17,23 +17,19 @@ func (a testPrintAdapter) BuildPDFPayload([]byte) (any, error) { return nil, nil
1717 func (a testPrintAdapter) RenderPDFBody(*fpdf.Fpdf, any) error { return nil }1818 1919 func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {20- original := printAdapterRegistry21- printAdapterRegistry = map[string]PrintAdapter{}22- t.Cleanup(func() { printAdapterRegistry = original })23-2420 adapter := testPrintAdapter{25- canonical: "Word Search",26- aliases: []string{"wordsearch", "word-search"},21+ canonical: "Test Word Search",22+ aliases: []string{"testwordsearch", "test-word-search"},2723 }2824 RegisterPrintAdapter(adapter)2925 30- if !HasPrintAdapter("Word Search") {26+ if !HasPrintAdapter("Test Word Search") {3127 t.Fatal("expected canonical lookup to be supported")3228 }33- if !HasPrintAdapter("word_search") {29+ if !HasPrintAdapter("test_word_search") {3430 t.Fatal("expected underscore alias lookup to be supported")3531 }36- if !HasPrintAdapter("wordsearch") {32+ if !HasPrintAdapter("testwordsearch") {3733 t.Fatal("expected compact alias lookup to be supported")3834 }3935 if HasPrintAdapter("lights out") {
@@ -42,13 +38,9 @@ func TestPrintAdapterRegistryLookupAndAliases(t *testing.T) {
4238 }4339 4440 func TestRegisterPrintAdapterSkipsBlankCanonical(t *testing.T) {45- original := printAdapterRegistry46- printAdapterRegistry = map[string]PrintAdapter{}47- t.Cleanup(func() { printAdapterRegistry = original })48-4941 RegisterPrintAdapter(testPrintAdapter{canonical: " "})50- if len(printAdapterRegistry) != 0 {51- t.Fatalf("registry size = %d, want 0", len(printAdapterRegistry))42+ if HasPrintAdapter("should-not-exist") {43+ t.Fatal("blank canonical adapter should not create lookups")5244 }5345 }5446
M game/style.go +12 -1
M hashiwokakero/Gamemode.go +1 -1
M hashiwokakero/Model.go +25 -6
@@ -18,6 +18,11 @@ type Model struct {
1818 keys KeyMap1919 modeTitle string2020 showFullHelp bool21+ termWidth int22+ termHeight int23+ originX int24+ originY int25+ originValid bool2126 }2227 2328 func New(mode HashiMode, puzzle Puzzle) game.Gamer {
@@ -41,6 +46,19 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
4146 switch msg := msg.(type) {4247 case game.HelpToggleMsg:4348 m.showFullHelp = msg.Show49+ m.originValid = false50+ case tea.WindowSizeMsg:51+ m.termWidth = msg.Width52+ m.termHeight = msg.Height53+ m.originValid = false54+ case tea.MouseClickMsg:55+ if msg.Button == tea.MouseLeft {56+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {57+ if isl := m.puzzle.FindIslandAt(col, row); isl != nil {58+ m.cursorIsland = isl.ID59+ }60+ }61+ }4462 case tea.KeyPressMsg:4563 if m.selectedIsland != nil {4664 m = m.handleBridgeMode(msg)
@@ -78,7 +96,7 @@ func (m Model) handleBridgeMode(msg tea.KeyPressMsg) Model {
7896 m = m.cycleBridge(-1, 0)7997 case key.Matches(msg, m.keys.Right):8098 m = m.cycleBridge(1, 0)81- case key.Matches(msg, m.keys.Select), key.Matches(msg, m.keys.Cancel):99+ case key.Matches(msg, m.keys.Select):82100 m.selectedIsland = nil83101 }84102 return m
@@ -122,6 +140,7 @@ func (m Model) cycleBridge(dx, dy int) Model {
122140 m.puzzle.SetBridge(*m.selectedIsland, adj.ID, newCount)123141 if m.puzzle.IsSolved() {124142 m.selectedIsland = nil143+ m.originValid = false125144 }126145 return m127146 }
@@ -138,13 +157,12 @@ func (m Model) View() string {
138157 139158 selected := m.selectedIsland != nil140159 status := statusBarView(selected, m.showFullHelp)141-142- return game.ComposeGameViewRows(143- title,144- grid,160+ rows := []game.SecondaryRow{145161 game.StaticRow(info),146162 game.StableRow(status, statusBarVariants()...),147- )163+ }164+165+ return game.ComposeGameViewRows(title, grid, rows...)148166 }149167 150168 func (m Model) SetTitle(t string) game.Gamer {
@@ -164,6 +182,7 @@ func (m Model) Reset() game.Gamer {
164182 m.cursorIsland = m.puzzle.Islands[0].ID165183 }166184 m.selectedIsland = nil185+ m.originValid = false167186 return m168187 }169188
M hashiwokakero/PrintAdapter.go +3 -4
M hashiwokakero/README.md +3 -3
M hashiwokakero/benchmark_test.go +0 -4
M hashiwokakero/generation_invariants_test.go +0 -4
M hashiwokakero/hashiwokakero_test.go +346 -1
@@ -2,9 +2,16 @@ package hashiwokakero
22 33 import (44 "encoding/json"5+ "image"6+ "image/color"7+ "strings"58 "testing"69 10+ tea "charm.land/bubbletea/v2"711 "charm.land/lipgloss/v2"12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/theme"14+ "github.com/charmbracelet/x/ansi"815 )916 1017 // --- helpers ---
@@ -166,6 +173,27 @@ func TestSetBridge(t *testing.T) {
166173 })167174 }168175 176+func TestSelectTogglesBridgeMode(t *testing.T) {177+ p := linePuzzle()178+ m := Model{179+ puzzle: *p,180+ cursorIsland: 0,181+ keys: DefaultKeyMap,182+ }183+184+ next, _ := m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})185+ selected := next.(Model)186+ if selected.selectedIsland == nil || *selected.selectedIsland != 0 {187+ t.Fatal("expected enter to select the current island")188+ }189+190+ next, _ = selected.Update(tea.KeyPressMsg{Code: tea.KeyEnter})191+ deselected := next.(Model)192+ if deselected.selectedIsland != nil {193+ t.Fatal("expected enter to deselect the current island")194+ }195+}196+169197 // --- BridgeCount (P0) ---170198 171199 func TestBridgeCount(t *testing.T) {
@@ -359,6 +387,323 @@ func TestViewHeightStableAcrossStatusModes(t *testing.T) {
359387 }360388 }361389 390+func TestScreenToGridUsesHashiCellWidth(t *testing.T) {391+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}392+393+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})394+ got := next.(Model)395+ x, y := got.cachedGridOrigin()396+397+ if col, row, ok := got.screenToGrid(x, y); !ok || col != 0 || row != 0 {398+ t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)399+ }400+ if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {401+ t.Fatal("screenToGrid(separator) = ok, want false")402+ }403+}404+405+func TestMouseClickMovesCursorToClickedIsland(t *testing.T) {406+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}407+408+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})409+ got := next.(Model)410+ ox, oy := got.cachedGridOrigin()411+412+ clickX := ox + (cellWidth+1)*3413+ clickY := oy414+ next, _ = got.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})415+ clicked := next.(Model)416+417+ if clicked.cursorIsland != 1 {418+ t.Fatalf("cursorIsland = %d, want %d", clicked.cursorIsland, 1)419+ }420+}421+422+func TestMouseClickIgnoresEmptyCells(t *testing.T) {423+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}424+425+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})426+ got := next.(Model)427+ ox, oy := got.cachedGridOrigin()428+429+ clickX := ox + (cellWidth + 1)430+ clickY := oy431+ next, _ = got.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})432+ clicked := next.(Model)433+434+ if clicked.cursorIsland != 0 {435+ t.Fatalf("cursorIsland = %d, want unchanged 0", clicked.cursorIsland)436+ }437+}438+439+func TestGridViewUsesOnlyOuterBorders(t *testing.T) {440+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap}441+442+ lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")443+ if got, want := len(lines), 9; got != want {444+ t.Fatalf("line count = %d, want %d", got, want)445+ }446+447+ if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {448+ t.Fatalf("top border = %q, want outer corners", lines[0])449+ }450+ if !strings.HasPrefix(lines[len(lines)-1], "└") || !strings.HasSuffix(lines[len(lines)-1], "┘") {451+ t.Fatalf("bottom border = %q, want outer corners", lines[len(lines)-1])452+ }453+454+ for _, idx := range []int{1, 3, 5, 7} {455+ if got := strings.Count(lines[idx], "│"); got != 2 {456+ t.Fatalf("content row %d = %q, want only outer vertical borders", idx, lines[idx])457+ }458+ }459+ for _, idx := range []int{2, 4, 6} {460+ if strings.Contains(lines[idx], "─") {461+ t.Fatalf("separator row %d = %q, want no interior horizontal border glyphs", idx, lines[idx])462+ }463+ }464+}465+466+func TestGridViewRendersSingleAndDoubleHorizontalBridgesAcrossSeparators(t *testing.T) {467+ makePuzzle := func(count int) Puzzle {468+ p := Puzzle{469+ Width: 3,470+ Height: 1,471+ Islands: []Island{472+ {ID: 0, X: 0, Y: 0, Required: count},473+ {ID: 1, X: 2, Y: 0, Required: count},474+ },475+ }476+ p.SetBridge(0, 1, count)477+ return p478+ }479+480+ single := Model{puzzle: makePuzzle(1), cursorIsland: 0, keys: DefaultKeyMap}481+ singleRow := strings.Split(ansi.Strip(gridView(single, false)), "\n")[1]482+ if got := strings.Count(singleRow, "━"); got != 7 {483+ t.Fatalf("single bridge row = %q, want 7 heavy horizontal bridge glyphs including separators", singleRow)484+ }485+486+ double := Model{puzzle: makePuzzle(2), cursorIsland: 0, keys: DefaultKeyMap}487+ doubleRow := strings.Split(ansi.Strip(gridView(double, false)), "\n")[1]488+ if got := strings.Count(doubleRow, "═"); got != 7 {489+ t.Fatalf("double bridge row = %q, want 7 double-bridge glyphs including separators", doubleRow)490+ }491+}492+493+func TestGridViewRendersVerticalBridgeInSeparatorRows(t *testing.T) {494+ p := Puzzle{495+ Width: 1,496+ Height: 3,497+ Islands: []Island{498+ {ID: 0, X: 0, Y: 0, Required: 1},499+ {ID: 1, X: 0, Y: 2, Required: 1},500+ },501+ }502+ p.SetBridge(0, 1, 1)503+504+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}505+ lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")506+507+ if !strings.Contains(lines[2], " ┃ ") {508+ t.Fatalf("upper separator row = %q, want vertical bridge glyph", lines[2])509+ }510+ if !strings.Contains(lines[3], " ┃ ") {511+ t.Fatalf("bridge cell row = %q, want vertical bridge glyph", lines[3])512+ }513+ if !strings.Contains(lines[4], " ┃ ") {514+ t.Fatalf("lower separator row = %q, want vertical bridge glyph", lines[4])515+ }516+}517+518+func TestResolveCellVisualCursorIsland(t *testing.T) {519+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap}520+521+ got := renderCellVisual(resolveCellVisual(m, 0, 0, false))522+ want := renderExpectedIslandCell(game.CursorLeft+"2"+game.CursorRight, game.CursorFG(), game.CursorBG(), true)523+ if got != want {524+ t.Fatalf("cursor island render = %q, want %q", got, want)525+ }526+}527+528+func TestResolveCellVisualSelectedIsland(t *testing.T) {529+ selected := 0530+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 1, selectedIsland: &selected, keys: DefaultKeyMap}531+532+ got := renderCellVisual(resolveCellVisual(m, 0, 0, false))533+ want := renderExpectedIslandCell(" 2 ", theme.TextOnBG(theme.Current().SelectionBG), theme.Current().SelectionBG, true)534+ if got != want {535+ t.Fatalf("selected island render = %q, want %q", got, want)536+ }537+}538+539+func TestResolveCellVisualSatisfiedAndOverfilledIslands(t *testing.T) {540+ satisfied := Puzzle{541+ Width: 3,542+ Height: 1,543+ Islands: []Island{544+ {ID: 0, X: 0, Y: 0, Required: 1},545+ {ID: 1, X: 2, Y: 0, Required: 1},546+ },547+ }548+ satisfied.SetBridge(0, 1, 1)549+ satisfiedModel := Model{puzzle: satisfied, cursorIsland: 1, keys: DefaultKeyMap}550+551+ gotSatisfied := renderCellVisual(resolveCellVisual(satisfiedModel, 0, 0, false))552+ wantSatisfied := renderExpectedIslandCell(" 1 ", theme.TextOnBG(theme.Current().SuccessBG), theme.Current().SuccessBG, true)553+ if gotSatisfied != wantSatisfied {554+ t.Fatalf("satisfied island render = %q, want %q", gotSatisfied, wantSatisfied)555+ }556+557+ overfilled := Puzzle{558+ Width: 5,559+ Height: 3,560+ Islands: []Island{561+ {ID: 0, X: 0, Y: 1, Required: 2},562+ {ID: 1, X: 2, Y: 1, Required: 2},563+ {ID: 2, X: 4, Y: 1, Required: 2},564+ {ID: 3, X: 2, Y: 0, Required: 1},565+ },566+ }567+ overfilled.SetBridge(0, 1, 1)568+ overfilled.SetBridge(1, 2, 1)569+ overfilled.SetBridge(1, 3, 1)570+ overfilledModel := Model{puzzle: overfilled, cursorIsland: 0, keys: DefaultKeyMap}571+572+ gotOverfilled := renderCellVisual(resolveCellVisual(overfilledModel, 2, 1, false))573+ wantOverfilled := renderExpectedIslandCell(" 2 ", theme.TextOnBG(theme.Current().ErrorBG), theme.Current().ErrorBG, true)574+ if gotOverfilled != wantOverfilled {575+ t.Fatalf("overfilled island render = %q, want %q", gotOverfilled, wantOverfilled)576+ }577+}578+579+func TestResolveCellVisualSolvedIsland(t *testing.T) {580+ p := Puzzle{581+ Width: 1,582+ Height: 1,583+ Islands: []Island{584+ {ID: 0, X: 0, Y: 0, Required: 1},585+ },586+ }587+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}588+589+ unsolved := resolveCellVisual(m, 0, 0, false)590+ solved := resolveCellVisual(m, 0, 0, true)591+592+ if sameColor(unsolved.bg, solved.bg) {593+ t.Fatal("expected solved island background to differ from unsolved island background")594+ }595+596+ got := renderCellVisual(solved)597+ want := renderExpectedIslandCellWithOuterBG(598+ " 1 ",599+ theme.Current().SolvedFG,600+ solvedIslandBackground(),601+ solvedBoardBackground(),602+ true,603+ )604+ if got != want {605+ t.Fatalf("solved island render = %q, want %q", got, want)606+ }607+}608+609+func TestSolvedBridgeVisualStaysLegibleOnBoardBackground(t *testing.T) {610+ p := Puzzle{611+ Width: 3,612+ Height: 1,613+ Islands: []Island{614+ {ID: 0, X: 0, Y: 0, Required: 1},615+ {ID: 1, X: 2, Y: 0, Required: 1},616+ },617+ }618+ p.SetBridge(0, 1, 1)619+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}620+621+ island := resolveCellVisual(m, 0, 0, true)622+ if !sameColor(island.outerBG, solvedBoardBackground()) {623+ t.Fatal("expected solved island outer background to use the solved board color")624+ }625+626+ bridge := resolveCellVisual(m, 1, 0, true)627+ if !sameColor(bridge.bg, solvedBoardBackground()) {628+ t.Fatal("expected solved bridge cell to use the solved board color")629+ }630+ if !sameColor(bridge.fg, bridgeColor(0)) {631+ t.Fatal("expected solved bridge cell to use the bridge palette color")632+ }633+}634+635+func TestResolveCellVisualSolvedEmptyCellUsesSolvedBoardBackground(t *testing.T) {636+ m := Model{puzzle: *linePuzzle(), cursorIsland: 0, keys: DefaultKeyMap}637+638+ empty := resolveCellVisual(m, 1, 0, true)639+ if !sameColor(empty.bg, solvedBoardBackground()) {640+ t.Fatal("expected solved empty cell to use the solved board color")641+ }642+}643+644+func TestBridgeForegroundUsesThemePalettePerBridge(t *testing.T) {645+ p := Puzzle{646+ Width: 5,647+ Height: 1,648+ Islands: []Island{649+ {ID: 0, X: 0, Y: 0, Required: 1},650+ {ID: 1, X: 2, Y: 0, Required: 2},651+ {ID: 2, X: 4, Y: 0, Required: 1},652+ },653+ }654+ p.SetBridge(0, 1, 1)655+ p.SetBridge(1, 2, 1)656+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}657+658+ leftBridge := game.DynamicGridBridge{659+ Kind: game.DynamicGridBridgeVertical,660+ X: 1,661+ Y: 0,662+ Count: 2,663+ Cells: [4]image.Point{{X: 0, Y: 0}, {X: 1, Y: 0}},664+ }665+ rightBridge := game.DynamicGridBridge{666+ Kind: game.DynamicGridBridgeVertical,667+ X: 3,668+ Y: 0,669+ Count: 2,670+ Cells: [4]image.Point{{X: 2, Y: 0}, {X: 3, Y: 0}},671+ }672+673+ if !sameColor(bridgeForeground(m, leftBridge), bridgeColor(0)) {674+ t.Fatal("expected first bridge to use the first palette color")675+ }676+ if !sameColor(bridgeForeground(m, rightBridge), bridgeColor(1)) {677+ t.Fatal("expected second bridge to use the second palette color")678+ }679+}680+681+func sameColor(left, right color.Color) bool {682+ if left == nil || right == nil {683+ return left == nil && right == nil684+ }685+ lr, lg, lb, la := left.RGBA()686+ rr, rg, rb, ra := right.RGBA()687+ return lr == rr && lg == rg && lb == rb && la == ra688+}689+690+func renderExpectedIslandCell(text string, fg, bg color.Color, bold bool) string {691+ return renderExpectedIslandCellWithOuterBG(text, fg, bg, boardBackground(), bold)692+}693+694+func renderExpectedIslandCellWithOuterBG(text string, fg, bg, outerBG color.Color, bold bool) string {695+ side := lipgloss.NewStyle().Width((cellWidth - islandPillWidth) / 2).Background(outerBG).Render("")696+ center := lipgloss.NewStyle().697+ Width(islandPillWidth).698+ AlignHorizontal(lipgloss.Center).699+ Foreground(fg).700+ Background(bg)701+ if bold {702+ center = center.Bold(true)703+ }704+ return side + center.Render(text) + side705+}706+362707 // --- IsConnected (P0) ---363708 364709 func TestIsConnected(t *testing.T) {
@@ -866,7 +1211,7 @@ func TestPlaceIslands(t *testing.T) {
8661211 if islands == nil {8671212 continue8681213 }869- for i := 0; i < len(islands); i++ {1214+ for i := range len(islands) {8701215 for j := i + 1; j < len(islands); j++ {8711216 dx := abs(islands[i].X - islands[j].X)8721217 dy := abs(islands[i].Y - islands[j].Y)
M hashiwokakero/help.md +2 -2
M hashiwokakero/keys.go +2 -7
A hashiwokakero/mouse.go +37 -0
M hashiwokakero/style.go +268 -202
@@ -3,6 +3,7 @@ package hashiwokakero
33 import (44 "fmt"55 "image/color"6+ "slices"67 "strings"78 89 "charm.land/lipgloss/v2"
@@ -10,84 +11,63 @@ import (
1011 "github.com/FelineStateMachine/puzzletea/theme"1112 )1213 13-const cellWidth = 314+const cellWidth = 51415 15-func islandColors() []color.Color { return theme.Current().ThemeColors() }16+const islandPillWidth = game.DynamicGridCellWidth1617 17-func islandDefaultStyle(islandID int) lipgloss.Style {18- colors := islandColors()19- bg := colors[islandID%len(colors)]20- return lipgloss.NewStyle().21- Foreground(theme.TextOnBG(bg)).22- Background(bg).23- Bold(true)18+type cellVisual struct {19+ text string20+ fg color.Color21+ bg color.Color22+ outerBG color.Color23+ bold bool24+ pill bool2425 }2526 26-func islandSatisfiedStyle() lipgloss.Style {27- p := theme.Current()28- return lipgloss.NewStyle().29- Foreground(p.SuccessBorder).30- Background(p.SuccessBG).31- Bold(true)27+func boardBackground() color.Color {28+ return theme.Current().BG3229 }3330 34-func islandOverStyle() lipgloss.Style {31+func baseIslandBackground() color.Color {3532 p := theme.Current()36- return lipgloss.NewStyle().37- Foreground(p.Error).38- Background(p.ErrorBG).39- Bold(true)40-}41-42-// islandHighlightBGStyle returns the style for adjacent/neighbor islands.43-44-func islandAdjacentStyle(islandID int) lipgloss.Style {45- colors := islandColors()46- bg := colors[islandID%len(colors)]47- return lipgloss.NewStyle().48- Foreground(theme.TextOnBG(bg)).49- Background(bg).50- Bold(true)33+ return theme.Blend(p.BG, p.Success, 0.45)5134 }5235 53-func bridgeStyle() lipgloss.Style {54- return lipgloss.NewStyle().55- Foreground(theme.Current().Linked)36+func adjacentIslandBackground() color.Color {37+ return theme.Blend(baseIslandBackground(), theme.Current().Highlight, 0.12)5638 }5739 58-func bridgeSolvedStyle() lipgloss.Style {59- return lipgloss.NewStyle().60- Foreground(theme.Current().SuccessBorder)40+func solvedIslandBackground() color.Color {41+ p := theme.Current()42+ return theme.Blend(p.BG, p.SuccessBorder, 0.55)6143 }6244 63-func emptyDotStyle() lipgloss.Style {64- return lipgloss.NewStyle().65- Foreground(theme.Current().TextDim)45+func solvedBoardBackground() color.Color {46+ return theme.Current().SuccessBG6647 }6748 68-func gridBorderStyle() lipgloss.Style {69- return lipgloss.NewStyle().70- Border(lipgloss.RoundedBorder()).71- Padding(1).72- BorderForeground(theme.Current().TextDim)49+func baseCellStyle(fg, bg color.Color, bold bool) lipgloss.Style {50+ style := lipgloss.NewStyle().51+ Width(cellWidth).52+ AlignHorizontal(lipgloss.Center).53+ Foreground(fg).54+ Background(bg)55+ if bold {56+ style = style.Bold(true)57+ }58+ return style7359 }7460 75-func gridBorderSolvedStyle() lipgloss.Style {76- return lipgloss.NewStyle().77- Border(lipgloss.RoundedBorder()).78- Padding(1).79- BorderForeground(theme.Current().SuccessBorder).80- BorderBackground(theme.Current().SuccessBG)61+func bridgeColors() []color.Color {62+ return theme.Current().ThemeColors()8163 }8264 83-// islandSelectedStyle returns the style for a selected island, using the84-// current theme's Highlight color for the background.85-func islandSelectedStyle() lipgloss.Style {86- p := theme.Current()87- return lipgloss.NewStyle().88- Foreground(p.Highlight).89- Background(p.SelectionBG).90- Bold(true)65+func bridgeColor(index int) color.Color {66+ colors := bridgeColors()67+ if index < 0 || len(colors) == 0 {68+ return theme.TextOnBG(boardBackground())69+ }70+ return colors[index%len(colors)]9171 }9272 9373 // isHighlightedNeighbor returns true if islandID is directly connectable
@@ -109,193 +89,278 @@ func isHighlightedNeighbor(m Model, islandID int) bool {
10989 return false11090 }11191 112-// emptyCellView renders an empty grid cell as a subtle dot.113-func emptyCellView(solved bool) string {114- p := theme.Current()115- s := emptyDotStyle()92+func resolveCellVisual(m Model, x, y int, solved bool) cellVisual {93+ boardBG := boardBackground()94+ cellBG := boardBG11695 if solved {117- s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)96+ cellBG = solvedBoardBackground()97+ }98+ visual := cellVisual{99+ text: " ",100+ fg: theme.TextOnBG(cellBG),101+ bg: cellBG,102+ outerBG: cellBG,118103 }119- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("\u00b7")120-}121104 122-// gridView renders the puzzle using a display grid with gap cells between123-// logical cells so that bridges between directly adjacent islands are visible.124-// Display grid is (2*width-1) x (2*height-1): even coords are logical cells,125-// odd coords are gap cells showing bridge connectors.126-func gridView(m Model, solved bool) string {127- dispW := 2*m.puzzle.Width - 1128- dispH := 2*m.puzzle.Height - 1129-130- var rows []string131- for dy := range dispH {132- var cells []string133- for dx := range dispW {134- cells = append(cells, displayCellView(m, dx, dy, solved))105+ ci := m.puzzle.CellContent(x, y)106+ switch ci.Kind {107+ case cellIsland:108+ isl := m.puzzle.FindIslandByID(ci.IslandID)109+ if isl == nil {110+ return visual135111 }136- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...))137- }138- grid := lipgloss.JoinVertical(lipgloss.Left, rows...)139112 140- if solved {141- return gridBorderSolvedStyle().Render(grid)113+ label := fmt.Sprintf(" %d ", isl.Required)114+ isCursor := m.cursorIsland == ci.IslandID115+ current := m.puzzle.BridgeCount(ci.IslandID)116+ islandBG := baseIslandBackground()117+ visual.pill = true118+ visual.outerBG = cellBG119+120+ switch {121+ case solved:122+ islandBG = solvedIslandBackground()123+ visual.fg = theme.Current().SolvedFG124+ visual.bg = islandBG125+ visual.bold = true126+ case m.selectedIsland != nil && *m.selectedIsland == ci.IslandID:127+ visual.fg = theme.TextOnBG(theme.Current().SelectionBG)128+ visual.bg = theme.Current().SelectionBG129+ visual.bold = true130+ case isCursor:131+ visual.fg = game.CursorFG()132+ visual.bg = game.CursorBG()133+ visual.bold = true134+ case current > isl.Required:135+ visual.fg = theme.TextOnBG(theme.Current().ErrorBG)136+ visual.bg = theme.Current().ErrorBG137+ visual.bold = true138+ case current == isl.Required:139+ visual.fg = theme.TextOnBG(theme.Current().SuccessBG)140+ visual.bg = theme.Current().SuccessBG141+ visual.bold = true142+ case isHighlightedNeighbor(m, ci.IslandID):143+ adjacentBG := adjacentIslandBackground()144+ visual.fg = theme.TextOnBG(adjacentBG)145+ visual.bg = adjacentBG146+ visual.bold = true147+ default:148+ visual.fg = theme.TextOnBG(islandBG)149+ visual.bg = islandBG150+ visual.bold = true151+ }152+153+ if isCursor && !solved {154+ label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight155+ }156+ visual.text = label157+158+ case cellBridgeH:159+ visual.text = horizontalBridgeGlyph(ci.BridgeCount)160+ visual.fg = bridgeColor(ci.BridgeIdx)161+ visual.bold = ci.BridgeCount == 2162+ case cellBridgeV:163+ visual.text = verticalBridgeGlyph(ci.BridgeCount)164+ visual.fg = bridgeColor(ci.BridgeIdx)165+ visual.bold = ci.BridgeCount == 2142166 }143- return gridBorderStyle().Render(grid)167+168+ return visual144169 }145170 146-// displayCellView renders a single cell in the display grid.147-func displayCellView(m Model, dx, dy int, solved bool) string {148- evenX := dx%2 == 0149- evenY := dy%2 == 0150-151- if evenX && evenY {152- // Logical cell position153- lx, ly := dx/2, dy/2154- ci := m.puzzle.CellContent(lx, ly)155- switch ci.Kind {156- case cellIsland:157- return islandView(m, ci.IslandID, solved)158- case cellBridgeH:159- return bridgeHView(ci.BridgeCount, solved)160- case cellBridgeV:161- return bridgeVView(ci.BridgeCount, solved)162- default:163- return emptyCellView(solved)171+func renderCellVisual(visual cellVisual) string {172+ if visual.pill {173+ sideWidth := max((cellWidth-islandPillWidth)/2, 0)174+ left := lipgloss.NewStyle().Width(sideWidth).Background(visual.outerBG).Render("")175+ right := lipgloss.NewStyle().Width(cellWidth - islandPillWidth - sideWidth).Background(visual.outerBG).Render("")176+ center := lipgloss.NewStyle().177+ Width(islandPillWidth).178+ AlignHorizontal(lipgloss.Center).179+ Foreground(visual.fg).180+ Background(visual.bg)181+ if visual.bold {182+ center = center.Bold(true)164183 }184+ return left + center.Render(visual.text) + right165185 }186+ return baseCellStyle(visual.fg, visual.bg, visual.bold).Render(visual.text)187+}166188 167- if !evenX && evenY {168- // Horizontal gap between (dx/2, dy/2) and (dx/2+1, dy/2)169- lx, ly := dx/2, dy/2170- return hGapView(m, lx, ly, solved)171- }189+func gridView(m Model, solved bool) string {190+ return game.RenderDynamicGrid(game.DynamicGridSpec{191+ Width: m.puzzle.Width,192+ Height: m.puzzle.Height,193+ CellWidth: cellWidth,194+ Solved: solved,195+ Cell: func(x, y int) string {196+ return renderCellVisual(resolveCellVisual(m, x, y, solved))197+ },198+ ZoneAt: func(_, _ int) int {199+ return 0200+ },201+ HasVerticalEdge: func(x, _ int) bool {202+ return x <= 0 || x >= m.puzzle.Width203+ },204+ HasHorizontalEdge: func(_, y int) bool {205+ return y <= 0 || y >= m.puzzle.Height206+ },207+ BridgeForeground: func(bridge game.DynamicGridBridge) color.Color {208+ return bridgeForeground(m, bridge)209+ },210+ BridgeBold: func(bridge game.DynamicGridBridge) bool {211+ return bridgeBold(m, bridge)212+ },213+ VerticalBridgeText: func(x, y int) string {214+ return horizontalSeparatorBridge(m, x, y)215+ },216+ HorizontalBridgeText: func(x, y int) string {217+ return verticalSeparatorBridge(m, x, y)218+ },219+ })220+}172221 173- if evenX && !evenY {174- // Vertical gap between (dx/2, dy/2) and (dx/2, dy/2+1)175- lx, ly := dx/2, dy/2176- return vGapView(m, lx, ly, solved)222+func horizontalBridgeGlyph(count int) string {223+ if count == 2 {224+ return "═════"177225 }178-179- // Odd x and odd y — intersection of gaps, always empty180- return lipgloss.NewStyle().Width(cellWidth).Render(" ")226+ return "━━━━━"181227 }182228 183-// hGapView renders the horizontal gap between logical cell (lx,ly) and (lx+1,ly).184-// Shows a bridge connector if both neighbors are islands connected by a bridge.185-func hGapView(m Model, lx, ly int, solved bool) string {186- left := m.puzzle.FindIslandAt(lx, ly)187- right := m.puzzle.FindIslandAt(lx+1, ly)188- if left != nil && right != nil {189- if b := m.puzzle.GetBridge(left.ID, right.ID); b != nil {190- return bridgeHView(b.Count, solved)191- }229+func verticalBridgeGlyph(count int) string {230+ if count == 2 {231+ return " ║ "192232 }193- // Also check if a bridge passes through this gap (non-adjacent islands)194- ci := m.puzzle.CellContent(lx, ly)195- if ci.Kind == cellBridgeH {196- return bridgeHView(ci.BridgeCount, solved)233+ return " ┃ "234+}235+236+func horizontalSeparatorBridge(m Model, x, y int) string {237+ count := horizontalBridgeCountAt(m.puzzle, x, y)238+ if count == 0 {239+ return ""197240 }198- ciR := m.puzzle.CellContent(lx+1, ly)199- if ciR.Kind == cellBridgeH {200- return bridgeHView(ciR.BridgeCount, solved)241+ if count == 2 {242+ return "═"201243 }202- return lipgloss.NewStyle().Width(cellWidth).Render(" ")244+ return "━"203245 }204246 205-// vGapView renders the vertical gap between logical cell (lx,ly) and (lx,ly+1).206-// Shows a bridge connector if both neighbors are islands connected by a bridge.207-func vGapView(m Model, lx, ly int, solved bool) string {208- top := m.puzzle.FindIslandAt(lx, ly)209- bottom := m.puzzle.FindIslandAt(lx, ly+1)210- if top != nil && bottom != nil {211- if b := m.puzzle.GetBridge(top.ID, bottom.ID); b != nil {212- return bridgeVView(b.Count, solved)213- }247+func verticalSeparatorBridge(m Model, x, y int) string {248+ count := verticalBridgeCountAt(m.puzzle, x, y)249+ if count == 0 {250+ return ""214251 }215- // Also check if a bridge passes through this gap (non-adjacent islands)216- ci := m.puzzle.CellContent(lx, ly)217- if ci.Kind == cellBridgeV {218- return bridgeVView(ci.BridgeCount, solved)219- }220- ciB := m.puzzle.CellContent(lx, ly+1)221- if ciB.Kind == cellBridgeV {222- return bridgeVView(ciB.BridgeCount, solved)252+ if count == 2 {253+ return " ║ "223254 }224- return lipgloss.NewStyle().Width(cellWidth).Render(" ")255+ return " ┃ "225256 }226257 227-func islandView(m Model, islandID int, solved bool) string {228- isl := m.puzzle.FindIslandByID(islandID)229- if isl == nil {230- return emptyDotStyle().Width(cellWidth).Render(" ")258+func horizontalBridgeCountAt(p Puzzle, x, y int) int {259+ if x <= 0 || x >= p.Width || y < 0 || y >= p.Height {260+ return 0231261 }232262 233- style := islandDefaultStyle(islandID)234- current := m.puzzle.BridgeCount(islandID)235- isCursor := m.cursorIsland == islandID236-237- if solved && isCursor {238- style = game.CursorSolvedStyle()239- } else if solved {240- colors := islandColors()241- bg := colors[islandID%len(colors)]242- style = lipgloss.NewStyle().243- Foreground(theme.TextOnBG(bg)).244- Background(bg).245- Bold(true)246- } else if m.selectedIsland != nil && *m.selectedIsland == islandID {247- style = islandSelectedStyle()248- } else if isCursor {249- style = game.CursorStyle()250- } else if current == isl.Required {251- style = islandSatisfiedStyle()252- } else if current > isl.Required {253- style = islandOverStyle()254- } else if isHighlightedNeighbor(m, islandID) {255- style = islandAdjacentStyle(islandID)263+ left := p.CellContent(x-1, y)264+ right := p.CellContent(x, y)265+266+ switch {267+ case left.Kind == cellBridgeH:268+ return left.BridgeCount269+ case right.Kind == cellBridgeH:270+ return right.BridgeCount271+ case left.Kind == cellIsland && right.Kind == cellIsland:272+ if b := p.GetBridge(left.IslandID, right.IslandID); b != nil {273+ return b.Count274+ }256275 }257276 258- label := fmt.Sprintf("%d", isl.Required)259- if isCursor && !solved {260- label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight261- return style.Render(label)262- }263- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(label)277+ return 0264278 }265279 266-func bridgeHView(count int, solved bool) string {267- r := "\u2500\u2500\u2500"268- if count == 2 {269- r = "\u2550\u2550\u2550"280+func verticalBridgeCountAt(p Puzzle, x, y int) int {281+ if y <= 0 || y >= p.Height || x < 0 || x >= p.Width {282+ return 0270283 }271- s := bridgeStyle()272- if solved {273- s = bridgeSolvedStyle()284+285+ top := p.CellContent(x, y-1)286+ bottom := p.CellContent(x, y)287+288+ switch {289+ case top.Kind == cellBridgeV:290+ return top.BridgeCount291+ case bottom.Kind == cellBridgeV:292+ return bottom.BridgeCount293+ case top.Kind == cellIsland && bottom.Kind == cellIsland:294+ if b := p.GetBridge(top.IslandID, bottom.IslandID); b != nil {295+ return b.Count296+ }274297 }275- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)298+299+ return 0276300 }277301 278-func bridgeVView(count int, solved bool) string {279- r := "\u2502"280- if count == 2 {281- r = "\u2551"302+func bridgeForeground(m Model, bridge game.DynamicGridBridge) color.Color {303+ return bridgeColor(bridgeIndexForDynamicBridge(&m.puzzle, bridge))304+}305+306+func bridgeBold(m Model, bridge game.DynamicGridBridge) bool {307+ return bridgeCountForDynamicBridge(&m.puzzle, bridge) == 2308+}309+310+func bridgeIndexForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {311+ _, index := dynamicBridgeIdentity(p, bridge)312+ return index313+}314+315+func bridgeCountForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {316+ count, _ := dynamicBridgeIdentity(p, bridge)317+ return count318+}319+320+func dynamicBridgeIdentity(p *Puzzle, bridge game.DynamicGridBridge) (int, int) {321+ for i := 0; i < bridge.Count; i++ {322+ cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)323+ switch cell.Kind {324+ case cellBridgeH, cellBridgeV:325+ return cell.BridgeCount, cell.BridgeIdx326+ }282327 }283- s := bridgeStyle()284- if solved {285- s = bridgeSolvedStyle()328+329+ ids := make([]int, 0, 2)330+ for i := 0; i < bridge.Count; i++ {331+ cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)332+ if cell.Kind != cellIsland {333+ continue334+ }335+ if !slices.Contains(ids, cell.IslandID) {336+ ids = append(ids, cell.IslandID)337+ }286338 }287- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)339+340+ if len(ids) == 2 {341+ if p.bridgeIndex == nil {342+ p.buildBridgeIndex()343+ }344+ if idx, ok := p.bridgeIndex[bridgeKey(ids[0], ids[1])]; ok {345+ if idx >= 0 && idx < len(p.Bridges) {346+ return p.Bridges[idx].Count, idx347+ }348+ return 0, idx349+ }350+ }351+352+ return 0, -1288353 }289354 290355 func statusBarView(selected, showFullHelp bool) string {291356 if selected {292357 if showFullHelp {293- return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space/esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")358+ return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect esc: menu ctrl+r: reset ctrl+h: help")294359 }295- return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space/esc: cancel")360+ return game.StatusBarStyle().Render("bridge mode arrows/wasd: build enter/space: deselect")296361 }297362 if showFullHelp {298- return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island ctrl+n: menu ctrl+r: reset ctrl+h: help")363+ return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island esc: menu ctrl+r: reset ctrl+h: help")299364 }300365 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island")301366 }
@@ -306,6 +371,7 @@ func statusBarVariants() []string {
306371 statusBarView(false, true),307372 statusBarView(true, false),308373 statusBarView(true, true),374+ game.StatusBarStyle().Render("arrows/wasd: build bridge\nenter/space: deselect"),309375 }310376 }311377
M hitori/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M hitori/Model.go +23 -0

Not shown: this page already shows 6000 lines of diff.

M hitori/PrintAdapter.go +3 -4

Not shown: this page already shows 6000 lines of diff.

M hitori/README.md +1 -1

Not shown: this page already shows 6000 lines of diff.

M hitori/generator.go +1 -3

Not shown: this page already shows 6000 lines of diff.

M hitori/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

M hitori/hitori_test.go +205 -0

Not shown: this page already shows 6000 lines of diff.

A hitori/mouse.go +36 -0

Not shown: this page already shows 6000 lines of diff.

M hitori/style.go +147 -78

Not shown: this page already shows 6000 lines of diff.

M justfile +7 -0

Not shown: this page already shows 6000 lines of diff.

M lightsout/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M lightsout/README.md +1 -1

Not shown: this page already shows 6000 lines of diff.

M lightsout/generator.go +3 -3

Not shown: this page already shows 6000 lines of diff.

M lightsout/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

M lightsout/style.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M nonogram/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M nonogram/Model.go +50 -26

Not shown: this page already shows 6000 lines of diff.

M nonogram/PrintAdapter.go +2 -3

Not shown: this page already shows 6000 lines of diff.

M nonogram/README.md +1 -1

Not shown: this page already shows 6000 lines of diff.

A nonogram/dynamic_grid_test.go +264 -0

Not shown: this page already shows 6000 lines of diff.

M nonogram/generator.go +1 -6

Not shown: this page already shows 6000 lines of diff.

M nonogram/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

M nonogram/mouse.go +53 -118

Not shown: this page already shows 6000 lines of diff.

M nonogram/nonogram_test.go +319 -143

Not shown: this page already shows 6000 lines of diff.

M nonogram/style.go +117 -71

Not shown: this page already shows 6000 lines of diff.

M nurikabe/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M nurikabe/Model.go +21 -12

Not shown: this page already shows 6000 lines of diff.

M nurikabe/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M nurikabe/README.md +5 -2

Not shown: this page already shows 6000 lines of diff.

M nurikabe/generator.go +2 -11

Not shown: this page already shows 6000 lines of diff.

M nurikabe/help.md +5 -3

Not shown: this page already shows 6000 lines of diff.

M nurikabe/mouse.go +21 -37

Not shown: this page already shows 6000 lines of diff.

M nurikabe/nurikabe_test.go +576 -26

Not shown: this page already shows 6000 lines of diff.

M nurikabe/solver.go +20 -12

Not shown: this page already shows 6000 lines of diff.

M nurikabe/style.go +247 -54

Not shown: this page already shows 6000 lines of diff.

M pdfexport/cover_art.go +5 -10

Not shown: this page already shows 6000 lines of diff.

M pdfexport/cover_art_test.go +2 -1

Not shown: this page already shows 6000 lines of diff.

M pdfexport/jsonl.go +2 -4

Not shown: this page already shows 6000 lines of diff.

M pdfexport/jsonl_test.go +87 -2

Not shown: this page already shows 6000 lines of diff.

M pdfexport/parse.go +1 -1

Not shown: this page already shows 6000 lines of diff.

A pdfexport/print_adapter.go +64 -0

Not shown: this page already shows 6000 lines of diff.

M pdfexport/printdata.go +124 -13

Not shown: this page already shows 6000 lines of diff.

M pdfexport/printdata_test.go +48 -0

Not shown: this page already shows 6000 lines of diff.

M pdfexport/render.go +4 -5

Not shown: this page already shows 6000 lines of diff.

M pdfexport/render_error_test.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M pdfexport/render_title.go +1 -3

Not shown: this page already shows 6000 lines of diff.

M pdfexport/types.go +5 -3

Not shown: this page already shows 6000 lines of diff.

A puzzle/puzzle.go +47 -0

Not shown: this page already shows 6000 lines of diff.

A registry/registry.go +218 -0

Not shown: this page already shows 6000 lines of diff.

M resolve/resolve.go +85 -65

Not shown: this page already shows 6000 lines of diff.

M resolve/resolve_test.go +86 -180

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/Gamemode.go +83 -8

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/README.md +5 -5

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/generator.go +137 -15

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/help.md +1 -1

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/rippleeffect_test.go +234 -40

Not shown: this page already shows 6000 lines of diff.

M rippleeffect/style.go +91 -42

Not shown: this page already shows 6000 lines of diff.

M session/session.go +33 -4

Not shown: this page already shows 6000 lines of diff.

M session/session_test.go +15 -0

Not shown: this page already shows 6000 lines of diff.

M shikaku/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M shikaku/Model.go +6 -7

Not shown: this page already shows 6000 lines of diff.

M shikaku/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M shikaku/README.md +4 -5

Not shown: this page already shows 6000 lines of diff.

M shikaku/benchmark_test.go +0 -4

Not shown: this page already shows 6000 lines of diff.

M shikaku/generation_invariants_test.go +0 -6

Not shown: this page already shows 6000 lines of diff.

M shikaku/help.md +3 -4

Not shown: this page already shows 6000 lines of diff.

M shikaku/keys.go +3 -8

Not shown: this page already shows 6000 lines of diff.

M shikaku/puzzle.go +24 -1

Not shown: this page already shows 6000 lines of diff.

M shikaku/shikaku_test.go +72 -53

Not shown: this page already shows 6000 lines of diff.

M shikaku/style.go +7 -104

Not shown: this page already shows 6000 lines of diff.

A stats/layout.go +55 -0

Not shown: this page already shows 6000 lines of diff.

M stats/stats.go +31 -322

Not shown: this page already shows 6000 lines of diff.

M stats/stats_test.go +47 -369

Not shown: this page already shows 6000 lines of diff.

M store/game_record.go +17 -1

Not shown: this page already shows 6000 lines of diff.

A store/metadata.go +93 -0

Not shown: this page already shows 6000 lines of diff.

A store/migrate.go +142 -0

Not shown: this page already shows 6000 lines of diff.

M store/stats.go +126 -11

Not shown: this page already shows 6000 lines of diff.

M store/stats_test.go +92 -0

Not shown: this page already shows 6000 lines of diff.

M store/store.go +127 -9

Not shown: this page already shows 6000 lines of diff.

M store/store_test.go +65 -0

Not shown: this page already shows 6000 lines of diff.

M sudoku/Model.go +24 -1

Not shown: this page already shows 6000 lines of diff.

M sudoku/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M sudoku/README.md +2 -1

Not shown: this page already shows 6000 lines of diff.

M sudoku/benchmark_test.go +10 -8

Not shown: this page already shows 6000 lines of diff.

A sudoku/dynamic_board_test.go +385 -0

Not shown: this page already shows 6000 lines of diff.

M sudoku/help.md +2 -1

Not shown: this page already shows 6000 lines of diff.

A sudoku/mouse.go +33 -0

Not shown: this page already shows 6000 lines of diff.

M sudoku/style.go +114 -112

Not shown: this page already shows 6000 lines of diff.

M sudoku/uniqueness_test.go +0 -3

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/Export.go +81 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/Gamemode.go +62 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/Model.go +175 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/PrintAdapter.go +113 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/README.md +52 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/cell.go +103 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/dynamic_board_test.go +145 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/generator.go +304 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/help.md +35 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/keys.go +46 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/mouse.go +33 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/style.go +251 -0

Not shown: this page already shows 6000 lines of diff.

A sudokurgb/sudokurgb_test.go +176 -0

Not shown: this page already shows 6000 lines of diff.

M takuzu/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M takuzu/Model.go +62 -13

Not shown: this page already shows 6000 lines of diff.

M takuzu/PrintAdapter.go +98 -34

Not shown: this page already shows 6000 lines of diff.

M takuzu/README.md +2 -1

Not shown: this page already shows 6000 lines of diff.

M takuzu/generator.go +38 -21

Not shown: this page already shows 6000 lines of diff.

M takuzu/grid.go +14 -0

Not shown: this page already shows 6000 lines of diff.

M takuzu/help.md +2 -1

Not shown: this page already shows 6000 lines of diff.

A takuzu/mouse.go +35 -0

Not shown: this page already shows 6000 lines of diff.

M takuzu/print_adapter_test.go +75 -0

Not shown: this page already shows 6000 lines of diff.

M takuzu/style.go +57 -62

Not shown: this page already shows 6000 lines of diff.

M takuzu/takuzu_test.go +162 -53

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/Export.go +107 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/Gamemode.go +73 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/Model.go +186 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/PrintAdapter.go +34 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/README.md +54 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/generator.go +519 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/grid.go +134 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/help.md +25 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/keys.go +49 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/logic.go +137 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/mouse.go +35 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/profile.go +337 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/style.go +174 -0

Not shown: this page already shows 6000 lines of diff.

A takuzuplus/takuzuplus_test.go +534 -0

Not shown: this page already shows 6000 lines of diff.

M ui/list.go +6 -1

Not shown: this page already shows 6000 lines of diff.

A ui/stats_view.go +169 -0

Not shown: this page already shows 6000 lines of diff.

M ui/table.go +37 -8

Not shown: this page already shows 6000 lines of diff.

M ui/ui_test.go +30 -5

Not shown: this page already shows 6000 lines of diff.

M vhs/fillomino.gif +0 -0

Binary file.

M vhs/hashiwokakero.gif +0 -0

Binary file.

M vhs/help.gif +0 -0

Binary file.

M vhs/hitori.gif +0 -0

Binary file.

M vhs/lightsout.gif +0 -0

Binary file.

M vhs/menu.gif +0 -0

Binary file.

M vhs/nonogram.gif +0 -0

Binary file.

M vhs/nurikabe.gif +0 -0

Binary file.

M vhs/rippleeffect.gif +0 -0

Binary file.

M vhs/shikaku.gif +0 -0

Binary file.

M vhs/stats.gif +0 -0

Binary file.

M vhs/sudoku.gif +0 -0

Binary file.

M vhs/takuzu.gif +0 -0

Binary file.

M vhs/wordsearch.gif +0 -0

Binary file.

A weekly/weekly.go +166 -0

Not shown: this page already shows 6000 lines of diff.

A weekly/weekly_test.go +146 -0

Not shown: this page already shows 6000 lines of diff.

M wordsearch/Gamemode.go +1 -1

Not shown: this page already shows 6000 lines of diff.

M wordsearch/Model.go +237 -4

Not shown: this page already shows 6000 lines of diff.

M wordsearch/PrintAdapter.go +1 -2

Not shown: this page already shows 6000 lines of diff.

M wordsearch/README.md +3 -3

Not shown: this page already shows 6000 lines of diff.

M wordsearch/generator.go +0 -15

Not shown: this page already shows 6000 lines of diff.

M wordsearch/help.md +3 -3

Not shown: this page already shows 6000 lines of diff.

M wordsearch/keys.go +2 -2

Not shown: this page already shows 6000 lines of diff.

M wordsearch/style.go +54 -3

Not shown: this page already shows 6000 lines of diff.

M wordsearch/wordsearch_test.go +177 -12

Not shown: this page already shows 6000 lines of diff.