refactor: upgrade to Bubble Tea v2 with mouse support
- commit
- 93913b96d55554cb57417365554eb5cea9c3792c
- parent
- 16f7659b677d80ab65d40869f995baee317b83b8
- parent
- 823ac74c2b02aeea19c4248cba14d5779c018258
- author
- FelineStateMachine <dami.etoile@gmail.com>
@@ -16,7 +16,7 @@ import (
1616 "github.com/FelineStateMachine/puzzletea/takuzu"1717 "github.com/FelineStateMachine/puzzletea/wordsearch"1818 19- "github.com/charmbracelet/bubbles/list"19+ "charm.land/bubbles/v2/list"2020 )2121 2222 // Entry pairs a SeededSpawner with metadata for the eligible daily pool.@@ -9,15 +9,18 @@ func initDebugRenderer() *glamour.TermRenderer {
99 glamour.WithChromaFormatter("terminal16m"),1010 )1111 if err != nil {12- panic(err)12+ return nil1313 }1414 return renderer1515 }1616 1717 func (m model) renderDebugInfo() string {18+ if m.debugRenderer == nil {19+ return m.game.GetDebugInfo()20+ }1821 s, err := m.debugRenderer.Render(m.game.GetDebugInfo())1922 if err != nil {20- panic(err)23+ return m.game.GetDebugInfo()2124 }2225 return s2326 }@@ -0,0 +1,50 @@
1+package game2+3+import (4+ "charm.land/lipgloss/v2"5+ "charm.land/lipgloss/v2/compat"6+)7+8+// GridBorderColors holds the color configuration for a grid border with9+// crosshair and solved-state highlighting.10+type GridBorderColors struct {11+ BorderFG compat.AdaptiveColor12+ BackgroundBG compat.AdaptiveColor13+ CrosshairBG compat.AdaptiveColor14+ SolvedBorderFG compat.AdaptiveColor15+ SolvedBG compat.AdaptiveColor16+}17+18+// BorderChar renders a single border character with optional crosshair highlighting.19+func BorderChar(ch string, colors GridBorderColors, solved, highlight bool) string {20+ s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)21+ if solved {22+ s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)23+ } else if highlight {24+ s = s.Background(colors.CrosshairBG)25+ }26+ return s.Render(ch)27+}28+29+// HBorderRow builds a horizontal border row (top or bottom) with per-column30+// crosshair highlighting. cellWidth is the visual width of each cell.31+func HBorderRow(w, cursorX, cellWidth int, left, right string, colors GridBorderColors, solved bool) string {32+ var parts []string33+ parts = append(parts, BorderChar(left, colors, solved, false))34+ segment := ""35+ for range cellWidth {36+ segment += "─"37+ }38+ for x := range w {39+ highlight := !solved && x == cursorX40+ s := lipgloss.NewStyle().Foreground(colors.BorderFG).Background(colors.BackgroundBG)41+ if solved {42+ s = lipgloss.NewStyle().Foreground(colors.SolvedBorderFG).Background(colors.SolvedBG)43+ } else if highlight {44+ s = s.Background(colors.CrosshairBG)45+ }46+ parts = append(parts, s.Render(segment))47+ }48+ parts = append(parts, BorderChar(right, colors, solved, false))49+ return lipgloss.JoinHorizontal(lipgloss.Top, parts...)50+}@@ -1,8 +1,8 @@
11 package game22 33 import (4- "github.com/charmbracelet/bubbles/key"5- tea "github.com/charmbracelet/bubbletea"4+ "charm.land/bubbles/v2/key"5+ tea "charm.land/bubbletea/v2"66 )77 88 // Cursor represents a 2D grid cursor position.@@ -12,7 +12,7 @@ type Cursor struct {
1212 1313 // Move updates the cursor position based on the key message and bounds.1414 // Returns true if the cursor moved.15-func (c *Cursor) Move(keys CursorKeyMap, msg tea.KeyMsg, maxX, maxY int) bool {15+func (c *Cursor) Move(keys CursorKeyMap, msg tea.KeyPressMsg, maxX, maxY int) bool {1616 switch {1717 case key.Matches(msg, keys.Up):1818 if c.Y > 0 {@@ -4,7 +4,7 @@ import (
44 "strings"55 "testing"66 7- tea "github.com/charmbracelet/bubbletea"7+ tea "charm.land/bubbletea/v2"88 )99 1010 // --- Cursor.Move (P0) ---@@ -12,11 +12,11 @@ import (
1212 func TestCursorMove(t *testing.T) {1313 keys := DefaultCursorKeyMap1414 15- keyMsg := func(k string) tea.KeyMsg {16- return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(k)}15+ keyMsg := func(k string) tea.KeyPressMsg {16+ return tea.KeyPressMsg{Code: rune(k[0]), Text: k}1717 }18- specialKeyMsg := func(k tea.KeyType) tea.KeyMsg {19- return tea.KeyMsg{Type: k}18+ specialKeyMsg := func(k rune) tea.KeyPressMsg {19+ return tea.KeyPressMsg{Code: k}2020 }2121 2222 t.Run("movement within bounds", func(t *testing.T) {@@ -24,7 +24,7 @@ func TestCursorMove(t *testing.T) {
2424 name string2525 startX int2626 startY int27- msg tea.KeyMsg27+ msg tea.KeyPressMsg2828 maxX int2929 maxY int3030 wantX int@@ -56,7 +56,7 @@ func TestCursorMove(t *testing.T) {
5656 name string5757 startX int5858 startY int59- msg tea.KeyMsg59+ msg tea.KeyPressMsg6060 maxX int6161 maxY int6262 }{@@ -81,7 +81,7 @@ func TestCursorMove(t *testing.T) {
8181 })8282 8383 t.Run("1x1 grid no movement", func(t *testing.T) {84- directions := []tea.KeyMsg{84+ directions := []tea.KeyPressMsg{8585 specialKeyMsg(tea.KeyUp),8686 specialKeyMsg(tea.KeyDown),8787 specialKeyMsg(tea.KeyLeft),@@ -101,7 +101,7 @@ func TestCursorMove(t *testing.T) {
101101 102102 t.Run("arrow keys work", func(t *testing.T) {103103 tests := []struct {104- msg tea.KeyMsg104+ msg tea.KeyPressMsg105105 wantX int106106 wantY int107107 }{@@ -4,9 +4,9 @@ package game
44 import (55 "math/rand/v2"66 7- "github.com/charmbracelet/bubbles/key"8- "github.com/charmbracelet/bubbles/list"9- tea "github.com/charmbracelet/bubbletea"7+ "charm.land/bubbles/v2/key"8+ "charm.land/bubbles/v2/list"9+ tea "charm.land/bubbletea/v2"1010 )1111 1212 // Gamer is the interface that an active game instance must implement.@@ -84,10 +84,6 @@ type SpawnCompleteMsg struct {
8484 // the full help display with Ctrl+H.8585 type HelpToggleMsg struct{ Show bool }8686 87-// ResetMsg is sent from the root model to games when the user wants to reset88-// the puzzle to its initial state (Ctrl+R).89-type ResetMsg struct{}90-9187 // Registry maps game type names to their import functions.9288 var Registry = map[string]func([]byte) (Gamer, error){}9389 @@ -4,26 +4,56 @@ import (
44 "fmt"55 "strings"66 7- "github.com/charmbracelet/lipgloss"7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"9+)10+11+// Shared cursor color tokens, used both in style construction and for12+// composite styling where a cursor color is applied on top of another style.13+var (14+ CursorFG = compat.AdaptiveColor{Light: lipgloss.Color("255"), Dark: lipgloss.Color("235")}15+ CursorBG = compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("173")}16+ CursorWarmBG = compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("214")}17+ ConflictFG = compat.AdaptiveColor{Light: lipgloss.Color("160"), Dark: lipgloss.Color("167")}18+ ConflictBG = compat.AdaptiveColor{Light: lipgloss.Color("224"), Dark: lipgloss.Color("52")}819 )920 1021 var (1122 titleStyle = lipgloss.NewStyle().1223 Bold(true).13- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).14- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "173"}).24+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("255"), Dark: lipgloss.Color("255")}).25+ Background(CursorBG).1526 Padding(0, 1)1627 1728 solvedBadgeStyle = lipgloss.NewStyle().1829 Bold(true).19- Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "78"})30+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("28"), Dark: lipgloss.Color("78")})31+32+ // CursorStyle highlights the cursor position with an accent background.33+ // Used by lightsout, sudoku, wordsearch.34+ CursorStyle = lipgloss.NewStyle().35+ Bold(true).36+ Foreground(CursorFG).37+ Background(CursorBG)38+39+ // CursorWarmStyle highlights the cursor with a warmer background.40+ // Used by hitori, takuzu, nonogram.41+ CursorWarmStyle = lipgloss.NewStyle().42+ Bold(true).43+ Foreground(CursorFG).44+ Background(CursorWarmBG)2045 2146 // CursorSolvedStyle highlights the cursor position on a solved grid.2247 // Shared across all puzzle types.2348 CursorSolvedStyle = lipgloss.NewStyle().2449 Bold(true).25- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).26- Background(lipgloss.AdaptiveColor{Light: "28", Dark: "28"})50+ Foreground(CursorFG).51+ Background(compat.AdaptiveColor{Light: lipgloss.Color("28"), Dark: lipgloss.Color("28")})52+53+ // StatusBarStyle is the shared style for the status/help bar below each puzzle grid.54+ StatusBarStyle = lipgloss.NewStyle().55+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("244"), Dark: lipgloss.Color("244")}).56+ MarginTop(1)2757 )2858 2959 // TitleBarView renders a title bar with the game name, mode name, and optional solved badge.@@ -3,7 +3,9 @@ module github.com/FelineStateMachine/puzzletea
33 go 1.24.544 55 require (6- github.com/charmbracelet/bubbletea v1.3.66+ charm.land/bubbles/v2 v2.0.0-rc.17+ charm.land/bubbletea/v2 v2.0.0-rc.28+ charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea79 github.com/spf13/cobra v1.10.2810 modernc.org/sqlite v1.44.3911 )@@ -12,7 +14,14 @@ require (
1214 github.com/alecthomas/chroma/v2 v2.14.0 // indirect1315 github.com/atotto/clipboard v0.1.4 // indirect1416 github.com/aymerick/douceur v0.2.0 // indirect17+ github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect18+ github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect1519 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect20+ github.com/charmbracelet/x/termios v0.1.1 // indirect21+ github.com/charmbracelet/x/windows v0.2.2 // indirect22+ github.com/clipperhouse/displaywidth v0.9.0 // indirect23+ github.com/clipperhouse/stringish v0.1.1 // indirect24+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect1625 github.com/dlclark/regexp2 v1.11.0 // indirect1726 github.com/dustin/go-humanize v1.0.1 // indirect1827 github.com/google/uuid v1.6.0 // indirect@@ -35,25 +44,20 @@ require (
3544 3645 require (3746 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect38- github.com/charmbracelet/bubbles v0.21.039- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect47+ github.com/charmbracelet/colorprofile v0.4.2 // indirect4048 github.com/charmbracelet/glamour v0.10.041- github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c66083442- github.com/charmbracelet/x/ansi v0.9.3 // indirect43- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect44- github.com/charmbracelet/x/term v0.2.1 // indirect45- github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect46- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect49+ github.com/charmbracelet/x/ansi v0.11.6 // indirect50+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect51+ github.com/charmbracelet/x/term v0.2.2 // indirect52+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect4753 github.com/mattn/go-isatty v0.0.20 // indirect48- github.com/mattn/go-localereader v0.0.1 // indirect49- github.com/mattn/go-runewidth v0.0.16 // indirect50- github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect54+ github.com/mattn/go-runewidth v0.0.19 // indirect5155 github.com/muesli/cancelreader v0.2.2 // indirect5256 github.com/muesli/termenv v0.16.0 // indirect5357 github.com/rivo/uniseg v0.4.7 // indirect5458 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect5559 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect56- golang.org/x/sync v0.17.0 // indirect57- golang.org/x/sys v0.37.0 // indirect60+ golang.org/x/sync v0.18.0 // indirect61+ golang.org/x/sys v0.41.0 // indirect5862 golang.org/x/text v0.24.0 // indirect5963 )@@ -1,3 +1,9 @@
1+charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM=2+charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=3+charm.land/bubbletea/v2 v2.0.0-rc.2 h1:TdTbUOFzbufDJmSz/3gomL6q+fR6HwfY+P13hXQzD7k=4+charm.land/bubbletea/v2 v2.0.0-rc.2/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=5+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea h1:XBmpGhIKPN8o9VjuXg+X5WXFsEqUs/YtPx0Q0zzmTTA=6+charm.land/lipgloss/v2 v2.0.0-beta.3.0.20260212100304-e18737634dea/go.mod h1:xylWHUuJWcFJqoGrKdZP8Z0y3THC6xqrnfl1IYDviTE=17 github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=28 github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=39 github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=@@ -8,37 +14,45 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
814 github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=915 github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=1016 github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=11-github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=12-github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=17+github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=18+github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=1319 github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=1420 github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=15-github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs=16-github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg=17-github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=18-github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=19-github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=20-github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=21+github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY=22+github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8=2123 github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=2224 github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=2325 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=2426 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=25-github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=26-github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=27+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 h1:OqDqxQZliC7C8adA7KjelW3OjtAxREfeHkNcd66wpeI=28+github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318/go.mod h1:Y6kE2GzHfkyQQVCSL9r2hwokSrIlHGzZG+71+wDYSZI=29+github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=30+github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=2731 github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=2832 github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=29-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=30-github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=33+github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=34+github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=35+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=36+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=3137 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=3238 github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=33-github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=34-github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=39+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=40+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=41+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=42+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=43+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=44+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=45+github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=46+github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=47+github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=48+github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=49+github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=50+github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=3551 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=3652 github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=3753 github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=3854 github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=3955 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=40-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=41-github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=4256 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=4357 github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=4458 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=@@ -53,19 +67,15 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
5367 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=5468 github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=5569 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=56-github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=57-github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=70+github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=71+github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=5872 github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=5973 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=60-github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=61-github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=6274 github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=63-github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=64-github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=75+github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=76+github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=6577 github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=6678 github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=67-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=68-github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=6979 github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=7080 github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=7181 github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=@@ -101,12 +111,11 @@ golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
101111 golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=102112 golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=103113 golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=104-golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=105-golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=106-golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=114+golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=115+golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=107116 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=108-golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=109-golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=117+golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=118+golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=110119 golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=111120 golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=112121 golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=@@ -6,10 +6,11 @@ import (
66 )77 88 type Save struct {9- Width int `json:"width"`10- Height int `json:"height"`11- Islands []Island `json:"islands"`12- Bridges []Bridge `json:"bridges"`9+ Width int `json:"width"`10+ Height int `json:"height"`11+ Islands []Island `json:"islands"`12+ Bridges []Bridge `json:"bridges"`13+ ModeTitle string `json:"mode_title,omitempty"`1314 }1415 1516 func ImportModel(data []byte) (*Model, error) {@@ -31,16 +32,18 @@ func ImportModel(data []byte) (*Model, error) {
3132 Bridges: save.Bridges,3233 },3334 cursorIsland: cursorIsland,35+ modeTitle: save.ModeTitle,3436 keys: DefaultKeyMap,3537 }, nil3638 }3739 3840 func (m Model) GetSave() ([]byte, error) {3941 save := Save{40- Width: m.puzzle.Width,41- Height: m.puzzle.Height,42- Islands: m.puzzle.Islands,43- Bridges: m.puzzle.Bridges,42+ Width: m.puzzle.Width,43+ Height: m.puzzle.Height,44+ Islands: m.puzzle.Islands,45+ Bridges: m.puzzle.Bridges,46+ ModeTitle: m.modeTitle,4447 }4548 jsonData, err := json.Marshal(save)4649 if err != nil {@@ -4,10 +4,10 @@ package hashiwokakero
44 import (55 "fmt"66 7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"710 "github.com/FelineStateMachine/puzzletea/game"8- "github.com/charmbracelet/bubbles/key"9- tea "github.com/charmbracelet/bubbletea"10- "github.com/charmbracelet/lipgloss"1111 )1212 1313 var _ game.Gamer = Model{}@@ -42,7 +42,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
4242 switch msg := msg.(type) {4343 case game.HelpToggleMsg:4444 m.showFullHelp = msg.Show45- case tea.KeyMsg:45+ case tea.KeyPressMsg:4646 if m.selectedIsland != nil {4747 m = m.handleBridgeMode(msg)4848 } else {@@ -52,7 +52,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5252 return m, nil5353 }5454 55-func (m Model) handleNavMode(msg tea.KeyMsg) Model {55+func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {5656 switch {5757 case key.Matches(msg, m.keys.Up):5858 m = m.moveCursor(0, -1)@@ -69,7 +69,7 @@ func (m Model) handleNavMode(msg tea.KeyMsg) Model {
6969 return m7070 }7171 72-func (m Model) handleBridgeMode(msg tea.KeyMsg) Model {72+func (m Model) handleBridgeMode(msg tea.KeyPressMsg) Model {7373 switch {7474 case key.Matches(msg, m.keys.Up):7575 m = m.cycleBridge(0, -1)@@ -1,8 +1,8 @@
11 package hashiwokakero22 33 import (4+ "charm.land/bubbles/v2/key"45 "github.com/FelineStateMachine/puzzletea/game"5- "github.com/charmbracelet/bubbles/key"66 )77 88 type KeyMap struct {@@ -14,7 +14,7 @@ type KeyMap struct {
1414 var DefaultKeyMap = KeyMap{1515 CursorKeyMap: game.DefaultCursorKeyMap,1616 Select: key.NewBinding(17- key.WithKeys("enter", " "),17+ key.WithKeys("enter", "space"),1818 key.WithHelp("enter/space", "Select"),1919 ),2020 Cancel: key.NewBinding(@@ -4,31 +4,30 @@ import (
44 "fmt"55 "strings"66 7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"79 "github.com/FelineStateMachine/puzzletea/game"8- "github.com/charmbracelet/lipgloss"910 )1011 1112 const cellWidth = 31213 1314 // Color palette — warm earth-tone theme (ANSI 256, adaptive for light/dark terminals)1415 var (15- colorIslandFg = lipgloss.AdaptiveColor{Dark: "236", Light: "187"}16- colorIslandBg = lipgloss.AdaptiveColor{Dark: "187", Light: "58"}17- colorSatisfiedFg = lipgloss.AdaptiveColor{Dark: "22", Light: "149"}18- colorSatisfiedBg = lipgloss.AdaptiveColor{Dark: "194", Light: "236"}19- colorOverFg = lipgloss.AdaptiveColor{Dark: "160", Light: "167"}20- colorOverBg = lipgloss.AdaptiveColor{Dark: "224", Light: "52"}21- colorCursorFg = lipgloss.AdaptiveColor{Dark: "255", Light: "235"}22- colorCursorBg = lipgloss.AdaptiveColor{Dark: "130", Light: "173"}23- colorSelectedFg = lipgloss.AdaptiveColor{Dark: "255", Light: "235"}24- colorSelectedBg = lipgloss.AdaptiveColor{Dark: "172", Light: "179"}25- colorAdjacentBg = lipgloss.AdaptiveColor{Dark: "223", Light: "58"}26- colorBridge = lipgloss.AdaptiveColor{Dark: "137", Light: "137"}27- colorBridgeSolved = lipgloss.AdaptiveColor{Dark: "22", Light: "149"}28- colorEmptyDot = lipgloss.AdaptiveColor{Dark: "252", Light: "239"}29- colorEmptySolved = lipgloss.AdaptiveColor{Dark: "151", Light: "107"}30- colorInfoSatisfied = lipgloss.AdaptiveColor{Dark: "22", Light: "149"}31- colorInfoText = lipgloss.AdaptiveColor{Dark: "137", Light: "137"}16+ colorIslandFg = compat.AdaptiveColor{Dark: lipgloss.Color("236"), Light: lipgloss.Color("187")}17+ colorIslandBg = compat.AdaptiveColor{Dark: lipgloss.Color("187"), Light: lipgloss.Color("58")}18+ colorSatisfiedFg = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}19+ colorSatisfiedBg = compat.AdaptiveColor{Dark: lipgloss.Color("194"), Light: lipgloss.Color("236")}20+ colorOverFg = compat.AdaptiveColor{Dark: lipgloss.Color("160"), Light: lipgloss.Color("167")}21+ colorOverBg = compat.AdaptiveColor{Dark: lipgloss.Color("224"), Light: lipgloss.Color("52")}22+ colorSelectedFg = compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("235")}23+ colorSelectedBg = compat.AdaptiveColor{Dark: lipgloss.Color("172"), Light: lipgloss.Color("179")}24+ colorAdjacentBg = compat.AdaptiveColor{Dark: lipgloss.Color("223"), Light: lipgloss.Color("58")}25+ colorBridge = compat.AdaptiveColor{Dark: lipgloss.Color("137"), Light: lipgloss.Color("137")}26+ colorBridgeSolved = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}27+ colorEmptyDot = compat.AdaptiveColor{Dark: lipgloss.Color("252"), Light: lipgloss.Color("239")}28+ colorEmptySolved = compat.AdaptiveColor{Dark: lipgloss.Color("151"), Light: lipgloss.Color("107")}29+ colorInfoSatisfied = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}30+ colorInfoText = compat.AdaptiveColor{Dark: lipgloss.Color("137"), Light: lipgloss.Color("137")}3231 )3332 3433 var (@@ -49,11 +48,7 @@ var (
4948 Background(colorOverBg).5049 Bold(true)5150 52- islandCursorStyle = baseStyle.53- Foreground(colorCursorFg).54- Background(colorCursorBg).55- Bold(true)56-51+ islandCursorStyle = game.CursorStyle5752 islandCursorSolvedStyle = game.CursorSolvedStyle5853 5954 islandSelectedStyle = baseStyle.@@ -81,10 +76,6 @@ var (
8176 emptyStyle = baseStyle.8277 Foreground(colorEmptyDot)8378 84- statusBarStyle = lipgloss.NewStyle().85- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).86- MarginTop(1)87-8879 gridBorderStyle = lipgloss.NewStyle().8980 Border(lipgloss.RoundedBorder()).9081 Padding(1).@@ -285,14 +276,14 @@ func bridgeVView(count int, solved bool) string {
285276 func statusBarView(selected, showFullHelp bool) string {286277 if selected {287278 if showFullHelp {288- return statusBarStyle.Render("arrows/wasd: build bridge enter/space/esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")279+ return game.StatusBarStyle.Render("arrows/wasd: build bridge enter/space/esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")289280 }290- return statusBarStyle.Render("arrows/wasd: build bridge enter/space/esc: cancel")281+ return game.StatusBarStyle.Render("arrows/wasd: build bridge enter/space/esc: cancel")291282 }292283 if showFullHelp {293- return statusBarStyle.Render("arrows/wasd: move enter/space: select island ctrl+n: menu ctrl+r: reset ctrl+h: help")284+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select island ctrl+n: menu ctrl+r: reset ctrl+h: help")294285 }295- return statusBarStyle.Render("enter/space: select island")286+ return game.StatusBarStyle.Render("enter/space: select island")296287 }297288 298289 func infoView(p *Puzzle) string {@@ -6,9 +6,9 @@ import (
66 77 "github.com/FelineStateMachine/puzzletea/game"88 9- "github.com/charmbracelet/bubbles/key"10- tea "github.com/charmbracelet/bubbletea"11- "github.com/charmbracelet/lipgloss"9+ "charm.land/bubbles/v2/key"10+ tea "charm.land/bubbletea/v2"11+ "charm.land/lipgloss/v2"1212 )1313 1414 // Model implements game.Gamer for Hitori.@@ -64,12 +64,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6464 switch msg := msg.(type) {6565 case game.HelpToggleMsg:6666 m.showFullHelp = msg.Show67- case game.ResetMsg:68- m.marks = cloneMarks(m.initialMarks)69- m.solved = m.checkSolved()70- m.conflicts = computeConflicts(m.numbers, m.marks, m.size)71- m.cursor = game.Cursor{}72- case tea.KeyMsg:67+ case tea.KeyPressMsg:7368 switch {7469 case key.Matches(msg, m.keys.ShadeCell):7570 if !m.solved {@@ -5,44 +5,32 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/game"77 8- "github.com/charmbracelet/lipgloss"8+ "charm.land/lipgloss/v2"9+ "charm.land/lipgloss/v2/compat"910 )1011 1112 var (1213 baseStyle = lipgloss.NewStyle()13- backgroundColor = lipgloss.AdaptiveColor{Light: "254", Dark: "235"}14+ backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}1415 1516 numberStyle = baseStyle.16- Foreground(lipgloss.AdaptiveColor{Light: "236", Dark: "250"}).17+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("236"), Dark: lipgloss.Color("250")}).1718 Background(backgroundColor)1819 1920 shadedStyle = baseStyle.20- Foreground(lipgloss.AdaptiveColor{Light: "254", Dark: "238"}).21- Background(lipgloss.AdaptiveColor{Light: "240", Dark: "238"})21+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("238")}).22+ Background(compat.AdaptiveColor{Light: lipgloss.Color("240"), Dark: lipgloss.Color("238")})2223 2324 circledStyle = baseStyle.24- Foreground(lipgloss.AdaptiveColor{Light: "25", Dark: "75"}).25+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("25"), Dark: lipgloss.Color("75")}).2526 Background(backgroundColor)2627 27- cursorStyle = baseStyle.28- Bold(true).29- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).30- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})31-3228 cursorSolvedStyle = game.CursorSolvedStyle3329 34- conflictStyle = baseStyle.35- Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "167"}).36- Background(lipgloss.AdaptiveColor{Light: "224", Dark: "52"})37-38- crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}39- solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}30+ crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}31+ solvedBG = compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")}4032 41- borderFG = lipgloss.AdaptiveColor{Light: "250", Dark: "240"}42-43- statusBarStyle = lipgloss.NewStyle().44- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).45- MarginTop(1)33+ borderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}4634 )4735 4836 const cellWidth = 3@@ -67,14 +55,14 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
6755 if isCursor && solved {6856 s = cursorSolvedStyle6957 } else if isCursor {70- s = s.Background(cursorStyle.GetBackground()).71- Foreground(cursorStyle.GetForeground()).58+ s = s.Background(game.CursorWarmBG).59+ Foreground(game.CursorFG).7260 Bold(true)7361 } else if solved {7462 s = s.Background(solvedBG)7563 } else if conflict {76- s = s.Background(conflictStyle.GetBackground()).77- Foreground(conflictStyle.GetForeground())64+ s = s.Background(game.ConflictBG).65+ Foreground(game.ConflictFG)7866 } else if inCursorRow || inCursorCol {7967 s = s.Background(crosshairBG)8068 }@@ -82,6 +70,14 @@ func cellView(num rune, mark cellMark, isCursor, inCursorRow, inCursorCol, solve
8270 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)8371 }8472 73+var gridBorderColors = game.GridBorderColors{74+ BorderFG: borderFG,75+ BackgroundBG: backgroundColor,76+ CrosshairBG: crosshairBG,77+ SolvedBorderFG: compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")},78+ SolvedBG: solvedBG,79+}80+8581 func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conflicts [][]bool) string {8682 w := 08783 if len(numbers) > 0 {@@ -93,7 +89,7 @@ func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conf
9389 var rowCells []string9490 inCursorRow := y == c.Y9591 96- rowCells = append(rowCells, borderChar("\u2502", solved, !solved && inCursorRow))92+ rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))9793 9894 for x, num := range row {9995 isCursor := x == c.X && y == c.Y@@ -102,47 +98,19 @@ func gridView(numbers grid, marks [][]cellMark, c game.Cursor, solved bool, conf
10298 rowCells = append(rowCells, cell)10399 }104100 105- rowCells = append(rowCells, borderChar("\u2502", solved, !solved && inCursorRow))101+ rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))106102 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))107103 }108104 109- topRow := hBorderRow(w, c.X, "\u256D", "\u256E", solved)110- botRow := hBorderRow(w, c.X, "\u2570", "\u256F", solved)105+ topRow := game.HBorderRow(w, c.X, cellWidth, "╭", "╮", gridBorderColors, solved)106+ botRow := game.HBorderRow(w, c.X, cellWidth, "╰", "╯", gridBorderColors, solved)111107 112108 return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)113109 }114110 115-func borderChar(ch string, solved, highlight bool) string {116- s := baseStyle.Foreground(borderFG).Background(backgroundColor)117- if solved {118- s = baseStyle.Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).Background(solvedBG)119- } else if highlight {120- s = s.Background(crosshairBG)121- }122- return s.Render(ch)123-}124-125-func hBorderRow(w, cursorX int, left, right string, solved bool) string {126- var parts []string127- parts = append(parts, borderChar(left, solved, false))128- segment := "\u2500\u2500\u2500" // ───129- for x := range w {130- highlight := !solved && x == cursorX131- s := baseStyle.Foreground(borderFG).Background(backgroundColor)132- if solved {133- s = baseStyle.Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).Background(solvedBG)134- } else if highlight {135- s = s.Background(crosshairBG)136- }137- parts = append(parts, s.Render(segment))138- }139- parts = append(parts, borderChar(right, solved, false))140- return lipgloss.JoinHorizontal(lipgloss.Top, parts...)141-}142-143111 func statusBarView(showFullHelp bool) string {144112 if showFullHelp {145- return statusBarStyle.Render("arrows/wasd: move x: shade z: circle bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")113+ return game.StatusBarStyle.Render("arrows/wasd: move x: shade z: circle bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")146114 }147- return statusBarStyle.Render("x: shade z: circle bkspc: clear")115+ return game.StatusBarStyle.Render("x: shade z: circle bkspc: clear")148116 }@@ -0,0 +1,37 @@
1+package main2+3+import "charm.land/bubbles/v2/key"4+5+type rootKeyMap struct {6+ Quit key.Binding7+ MainMenu key.Binding8+ Enter key.Binding9+ Escape key.Binding10+ Debug key.Binding11+ FullHelp key.Binding12+ ResetGame key.Binding13+}14+15+var rootKeys = rootKeyMap{16+ Quit: key.NewBinding(17+ key.WithKeys("ctrl+c"),18+ ),19+ MainMenu: key.NewBinding(20+ key.WithKeys("ctrl+n"),21+ ),22+ Enter: key.NewBinding(23+ key.WithKeys("enter"),24+ ),25+ Escape: key.NewBinding(26+ key.WithKeys("esc"),27+ ),28+ Debug: key.NewBinding(29+ key.WithKeys("ctrl+e"),30+ ),31+ FullHelp: key.NewBinding(32+ key.WithKeys("ctrl+h"),33+ ),34+ ResetGame: key.NewBinding(35+ key.WithKeys("ctrl+r"),36+ ),37+}@@ -8,18 +8,20 @@ import (
88 )99 1010 type Save struct {11- Grid [][]bool `json:"grid"`12- CursorX int `json:"cx"`13- CursorY int `json:"cy"`14- ModeTitle string `json:"mode_title"`11+ Grid [][]bool `json:"grid"`12+ InitialGrid [][]bool `json:"initial_grid,omitempty"`13+ CursorX int `json:"cx"`14+ CursorY int `json:"cy"`15+ ModeTitle string `json:"mode_title"`1516 }1617 1718 func (m Model) GetSave() ([]byte, error) {1819 save := Save{19- Grid: m.grid,20- CursorX: m.cursor.X,21- CursorY: m.cursor.Y,22- ModeTitle: m.modeTitle,20+ Grid: m.grid,21+ InitialGrid: m.initialGrid,22+ CursorX: m.cursor.X,23+ CursorY: m.cursor.Y,24+ ModeTitle: m.modeTitle,2325 }2426 jsonData, err := json.Marshal(save)2527 if err != nil {@@ -41,12 +43,24 @@ func ImportModel(data []byte) (*Model, error) {
4143 h := len(s.Grid)4244 w := len(s.Grid[0])4345 46+ // Fall back to a copy of the current grid for saves that predate47+ // the initialGrid field.48+ initial := s.InitialGrid49+ if len(initial) == 0 {50+ initial = make([][]bool, h)51+ for y := range h {52+ initial[y] = make([]bool, w)53+ copy(initial[y], s.Grid[y])54+ }55+ }56+4457 return &Model{45- grid: s.Grid,46- width: w,47- height: h,48- cursor: game.Cursor{X: s.CursorX, Y: s.CursorY},49- modeTitle: s.ModeTitle,50- keys: DefaultKeyMap,58+ grid: s.Grid,59+ initialGrid: initial,60+ width: w,61+ height: h,62+ cursor: game.Cursor{X: s.CursorX, Y: s.CursorY},63+ modeTitle: s.ModeTitle,64+ keys: DefaultKeyMap,5165 }, nil5266 }@@ -5,10 +5,10 @@ import (
55 "fmt"66 "math/rand/v2"77 8+ "charm.land/bubbles/v2/key"9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"811 "github.com/FelineStateMachine/puzzletea/game"9- "github.com/charmbracelet/bubbles/key"10- tea "github.com/charmbracelet/bubbletea"11- "github.com/charmbracelet/lipgloss"1212 )1313 1414 type Model struct {@@ -58,7 +58,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5858 switch msg := msg.(type) {5959 case game.HelpToggleMsg:6060 m.showFullHelp = msg.Show61- case tea.KeyMsg:61+ case tea.KeyPressMsg:6262 switch {6363 case key.Matches(msg, m.keys.Toggle):6464 if !m.IsSolved() {@@ -1,8 +1,8 @@
11 package lightsout22 33 import (4+ "charm.land/bubbles/v2/key"45 "github.com/FelineStateMachine/puzzletea/game"5- "github.com/charmbracelet/bubbles/key"66 )77 88 type KeyMap struct {@@ -14,7 +14,7 @@ var DefaultKeyMap = KeyMap{
1414 CursorKeyMap: game.DefaultCursorKeyMap,1515 1616 Toggle: key.NewBinding(17- key.WithKeys("enter", " "),17+ key.WithKeys("enter", "space"),1818 key.WithHelp("enter/space", "Toggle"),1919 ),2020 }@@ -3,15 +3,16 @@ package lightsout
33 import (44 "strings"55 6+ "charm.land/lipgloss/v2"7+ "charm.land/lipgloss/v2/compat"68 "github.com/FelineStateMachine/puzzletea/game"7- "github.com/charmbracelet/lipgloss"89 )910 1011 var (1112 baseStyle = lipgloss.NewStyle()1213 13- colorOn = lipgloss.AdaptiveColor{Light: "222", Dark: "180"}14- colorOff = lipgloss.AdaptiveColor{Light: "254", Dark: "236"}14+ colorOn = compat.AdaptiveColor{Light: lipgloss.Color("222"), Dark: lipgloss.Color("180")}15+ colorOff = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("236")}1516 1617 styleOn = baseStyle.1718 Background(colorOn)@@ -19,29 +20,21 @@ var (
1920 styleOff = baseStyle.2021 Background(colorOff)2122 22- cursorStyle = baseStyle.23- Bold(true).24- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).25- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "173"})26-23+ cursorStyle = game.CursorStyle2724 cursorSolvedStyle = game.CursorSolvedStyle2825 2926 solvedStyle = baseStyle.30- Background(lipgloss.AdaptiveColor{Light: "151", Dark: "22"})27+ Background(compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")})3128 3229 gridBorderStyle = lipgloss.NewStyle().3330 Border(lipgloss.RoundedBorder()).34- BorderForeground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"}).31+ BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).3532 BorderBackground(colorOff)3633 3734 gridBorderSolvedStyle = lipgloss.NewStyle().3835 Border(lipgloss.RoundedBorder()).39- BorderForeground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).40- BorderBackground(lipgloss.AdaptiveColor{Light: "151", Dark: "22"})41-42- statusBarStyle = lipgloss.NewStyle().43- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).44- MarginTop(1)36+ BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")}).37+ BorderBackground(compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")})4538 )4639 4740 const (@@ -87,7 +80,7 @@ func gridView(g [][]bool, c game.Cursor, solved bool) string {
8780 8881 func statusBarView(showFullHelp bool) string {8982 if showFullHelp {90- return statusBarStyle.Render("arrows/wasd: move enter/space: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")83+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: toggle ctrl+n: menu ctrl+r: reset ctrl+h: help")9184 }92- return statusBarStyle.Render("enter/space: toggle")85+ return game.StatusBarStyle.Render("enter/space: toggle")9386 }@@ -15,13 +15,13 @@ import (
1515 "github.com/FelineStateMachine/puzzletea/ui"1616 "github.com/FelineStateMachine/puzzletea/wordsearch"1717 18- "github.com/charmbracelet/bubbles/list"19- "github.com/charmbracelet/bubbles/spinner"20- "github.com/charmbracelet/bubbles/table"21- "github.com/charmbracelet/bubbles/viewport"22- tea "github.com/charmbracelet/bubbletea"18+ "charm.land/bubbles/v2/list"19+ "charm.land/bubbles/v2/spinner"20+ "charm.land/bubbles/v2/table"21+ "charm.land/bubbles/v2/viewport"22+ tea "charm.land/bubbletea/v2"23+ "charm.land/lipgloss/v2"2324 "github.com/charmbracelet/glamour"24- "github.com/charmbracelet/lipgloss"2525 )2626 2727 var (@@ -5,10 +5,10 @@ import (
55 "errors"66 "fmt"77 8+ "charm.land/bubbles/v2/key"9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"811 "github.com/FelineStateMachine/puzzletea/game"9- "github.com/charmbracelet/bubbles/key"10- tea "github.com/charmbracelet/bubbletea"11- "github.com/charmbracelet/lipgloss"1212 )1313 1414 const (@@ -30,6 +30,27 @@ type Model struct {
3030 showFullHelp bool3131 currentHints Hints // cached tomography of the current grid3232 solved bool // cached solved state33+34+ // Hold-to-paint support (requires keyboard enhancements).35+ paintBrush rune // 0 = inactive, filledTile or markedTile when painting36+ supportsKeyRelease bool // set from tea.KeyboardEnhancementsMsg37+38+ // Mouse drag support.39+ dragging rune // 0 = not dragging, filledTile or markedTile while mouse held40+41+ // Screen geometry for mouse hit-testing.42+ termWidth, termHeight int43+44+ // Cached grid origin for mouse hit-testing (recomputed on resize/solve).45+ originX, originY int46+ originValid bool47+48+ // Debug: last mouse event info.49+ lastMouseX, lastMouseY int50+ lastMouseBtn string51+ lastMouseGridCol int52+ lastMouseGridRow int53+ lastMouseHit bool3354 }3455 3556 func New(mode NonogramMode, hints Hints) (game.Gamer, error) {@@ -66,17 +87,90 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
6687 switch msg := msg.(type) {6788 case game.HelpToggleMsg:6889 m.showFullHelp = msg.Show69- case tea.KeyMsg:90+91+ case tea.KeyboardEnhancementsMsg:92+ m.supportsKeyRelease = msg.SupportsEventTypes()93+94+ case tea.WindowSizeMsg:95+ m.termWidth = msg.Width96+ m.termHeight = msg.Height97+ m.originValid = false98+99+ case tea.KeyPressMsg:70100 switch {71101 case key.Matches(msg, m.keys.FillTile):72- m.updateTile(filledTile)102+ if !m.solved {103+ m.updateTile(filledTile)104+ if m.supportsKeyRelease {105+ m.paintBrush = filledTile106+ }107+ }73108 case key.Matches(msg, m.keys.MarkTile):74- m.updateTile(markedTile)109+ if !m.solved {110+ m.updateTile(markedTile)111+ if m.supportsKeyRelease {112+ m.paintBrush = markedTile113+ }114+ }75115 case key.Matches(msg, m.keys.ClearTile):76- m.updateTile(emptyTile)116+ if !m.solved {117+ m.updateTile(emptyTile)118+ m.paintBrush = 0119+ }120+ default:121+ moved := m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)122+ if moved && m.paintBrush != 0 && !m.solved {123+ m.updateTile(m.paintBrush)124+ }125+ }126+127+ case tea.KeyReleaseMsg:128+ // Clear paint brush on key release. Cannot use key.Matches here129+ // because updateKeyBindings may have disabled the binding after the130+ // key was pressed (e.g. filling a cell disables FillTile).131+ switch msg.String() {132+ case "z", "x":133+ m.paintBrush = 0134+ }135+136+ case tea.MouseClickMsg:137+ m.lastMouseX, m.lastMouseY = msg.X, msg.Y138+ m.lastMouseBtn = msg.String()139+ col, row, ok := m.screenToGrid(msg.X, msg.Y)140+ m.lastMouseGridCol, m.lastMouseGridRow = col, row141+ m.lastMouseHit = ok142+ if m.solved || !ok {143+ break144+ }145+ m.cursor.X, m.cursor.Y = col, row146+ switch msg.Button {147+ case tea.MouseLeft:148+ m.toggleTile(filledTile)149+ m.dragging = filledTile150+ case tea.MouseRight:151+ m.toggleTile(markedTile)152+ m.dragging = markedTile77153 }78- m.cursor.Move(m.keys.CursorKeyMap, msg, m.width-1, m.height-1)154+155+ case tea.MouseMotionMsg:156+ if m.solved || m.dragging == 0 {157+ break158+ }159+ col, row, ok := m.screenToGrid(msg.X, msg.Y)160+ if !ok {161+ break162+ }163+ if col == m.cursor.X && row == m.cursor.Y {164+ break165+ }166+ m.cursor.X, m.cursor.Y = col, row167+ // While dragging, always set (don't toggle) to maintain consistency.168+ m.updateTile(m.dragging)169+170+ case tea.MouseReleaseMsg:171+ m.dragging = 079172 }173+80174 m.updateKeyBindings()81175 return m, nil82176 }@@ -105,11 +199,20 @@ func (m Model) GetDebugInfo() string {
105199 status = "Solved"106200 }107201 202+ ox, oy := m.gridOrigin()203+ hitStr := "miss"204+ if m.lastMouseHit {205+ hitStr = fmt.Sprintf("(%d, %d)", m.lastMouseGridCol, m.lastMouseGridRow)206+ }207+108208 s := game.DebugHeader("Nonogram", [][2]string{109209 {"Status", status},110210 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},111211 {"Grid Size", fmt.Sprintf("%d x %d", m.width, m.height)},112- {"Hint Widths", fmt.Sprintf("row: %d, col: %d", m.rowHints.RequiredLen()*cellWidth, m.colHints.RequiredLen())},212+ {"Term Size", fmt.Sprintf("%d x %d", m.termWidth, m.termHeight)},213+ {"Grid Origin", fmt.Sprintf("(%d, %d)", ox, oy)},214+ {"Last Mouse", fmt.Sprintf("screen=(%d, %d) btn=%s grid=%s", m.lastMouseX, m.lastMouseY, m.lastMouseBtn, hitStr)},215+ {"Paint/Drag", fmt.Sprintf("brush=%d drag=%d keyrel=%v", m.paintBrush, m.dragging, m.supportsKeyRelease)},113216 })114217 115218 s += tomoDebugTable("Row Tomography", "Row", m.rowHints, m.currentHints.rows)@@ -166,11 +269,29 @@ func (m Model) Reset() game.Gamer {
166269 m.currentHints = curr167270 m.solved = false168271 m.cursor = game.Cursor{}272+ m.paintBrush = 0273+ m.dragging = 0274+ m.originValid = false169275 return m170276 }171277 172278 func (m *Model) updateTile(r rune) {173279 m.grid[m.cursor.Y][m.cursor.X] = r174280 m.currentHints = generateTomography(m.grid)281+ wasSolved := m.solved175282 m.solved = m.currentHints.rows.equal(m.rowHints) && m.currentHints.cols.equal(m.colHints)283+ if m.solved != wasSolved {284+ m.originValid = false285+ }286+}287+288+// toggleTile toggles a cell between the given target state and empty.289+// If the cell already has the target value, it becomes empty; otherwise290+// it becomes the target.291+func (m *Model) toggleTile(target rune) {292+ if m.grid[m.cursor.Y][m.cursor.X] == target {293+ m.updateTile(emptyTile)294+ } else {295+ m.updateTile(target)296+ }176297 }@@ -0,0 +1,141 @@
1+package nonogram2+3+import (4+ "strings"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+// screenToGrid converts terminal screen coordinates to grid cell coordinates.11+// Returns (col, row, ok) where ok is false if the click landed outside the grid12+// or on a separator.13+func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {14+ ox, oy := m.cachedGridOrigin()15+ lx := screenX - ox16+ ly := screenY - oy17+ if lx < 0 || ly < 0 {18+ return 0, 0, false19+ }20+21+ col = localToCellX(lx, m.width)22+ row = localToCellY(ly, m.height)23+ if col < 0 || col >= m.width || row < 0 || row >= m.height {24+ return 0, 0, false25+ }26+27+ return col, row, true28+}29+30+// localToCellX converts a local horizontal offset within the grid area to a31+// column index, accounting for spacer separators every spacerEvery cells.32+// Each cell is cellWidth (3) characters wide; separators are 1 char wide.33+// Returns -1 if the position falls on a separator or out of bounds.34+func localToCellX(pos, count int) int {35+ return localToCell(pos, count, cellWidth)36+}37+38+// localToCellY converts a local vertical offset within the grid area to a39+// row index. Each row is 1 terminal line tall; separators are 1 line tall.40+// Returns -1 if the position falls on a separator or out of bounds.41+func localToCellY(pos, count int) int {42+ return localToCell(pos, count, 1)43+}44+45+// localToCell is the generic conversion for both axes.46+// unitWidth is the visual width of one cell (cellWidth for X, 1 for Y).47+func localToCell(pos, count, unitWidth int) int {48+ if count <= spacerEvery {49+ cell := pos / unitWidth50+ if cell >= count {51+ return -152+ }53+ return cell54+ }55+56+ blockWidth := spacerEvery*unitWidth + 157+ block := pos / blockWidth58+ rem := pos % blockWidth59+60+ cellInBlock := rem / unitWidth61+ if cellInBlock >= spacerEvery {62+ return -163+ }64+65+ cell := block*spacerEvery + cellInBlock66+ if cell >= count {67+ return -168+ }69+ return cell70+}71+72+// cachedGridOrigin returns the screen position of the top-left corner of73+// cell (0,0), using a cached value when available. The cache is invalidated74+// on terminal resize and solve-state changes.75+func (m *Model) cachedGridOrigin() (x, y int) {76+ if m.originValid {77+ return m.originX, m.originY78+ }79+ x, y = m.gridOrigin()80+ m.originX, m.originY = x, y81+ m.originValid = true82+ return x, y83+}84+85+// gridOrigin computes the screen position of the top-left corner of the86+// first grid cell. Mouse coordinates are terminal-absolute, so we need to87+// account for how the root model centers the game's View() output.88+//89+// The root view does:90+//91+// lipgloss.Place(termWidth, termHeight, Center, Center, gameView)92+//93+// So the centering offset is (termWidth - viewWidth) / 2 for X and94+// (termHeight - viewHeight) / 2 for Y.95+//96+// Within the game view, the grid cell (0,0) is offset by the row hint97+// area width (X) and the title + column hint area height (Y).98+func (m *Model) gridOrigin() (x, y int) {99+ // Render the game view to get its exact dimensions.100+ gameView := m.View()101+ viewWidth := lipgloss.Width(gameView)102+ viewHeight := lipgloss.Height(gameView)103+104+ // The root view caps the game view to terminal width before centering.105+ if viewWidth > m.termWidth {106+ viewWidth = m.termWidth107+ }108+109+ // Centering offset applied by the root's lipgloss.Place().110+ centerX := max((m.termWidth-viewWidth)/2, 0)111+ centerY := max((m.termHeight-viewHeight)/2, 0)112+113+ // Within the game view, find where cell (0,0) starts.114+ // Render the subcomponents to measure their sizes.115+ maxWidth := m.rowHints.RequiredLen() * cellWidth116+ maxHeight := m.colHints.RequiredLen()117+118+ title := game.TitleBarView("Nonogram", m.modeTitle, m.solved)119+ rowHints := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)120+ colHints := colHintView(m.colHints, maxHeight, m.currentHints.cols)121+ spacer := baseStyle.Width(maxWidth).Height(maxHeight).Render("")122+123+ s1 := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, colHints)124+ gridBlock := lipgloss.JoinVertical(lipgloss.Center, s1, "placeholder")125+126+ // The grid block may be narrower than the full view (title or status127+ // could be wider). JoinVertical(Center) pads to the widest line.128+ gridBlockWidth := lipgloss.Width(gridBlock)129+ gridBlockPadLeft := max((viewWidth-gridBlockWidth)/2, 0)130+131+ // The row hint width within the grid block.132+ hintAreaWidth := lipgloss.Width(rowHints)133+134+ // Vertical: title lines + column hint area lines.135+ titleHeight := strings.Count(title, "\n") + 1136+ colHintAreaHeight := strings.Count(s1, "\n") + 1137+138+ x = centerX + gridBlockPadLeft + hintAreaWidth139+ y = centerY + titleHeight + colHintAreaHeight140+ return x, y141+}@@ -983,3 +983,172 @@ func TestReset(t *testing.T) {
983983 t.Error("solved should be false after reset")984984 }985985 }986+987+// --- localToCell (P0) ---988+989+func TestLocalToCellX(t *testing.T) {990+ t.Run("small grid no spacers", func(t *testing.T) {991+ // 5 cells, cellWidth=3, no separators.992+ tests := []struct {993+ pos int994+ want int995+ }{996+ {0, 0}, {1, 0}, {2, 0}, // cell 0997+ {3, 1}, {4, 1}, {5, 1}, // cell 1998+ {6, 2}, {7, 2}, {8, 2}, // cell 2999+ {9, 3}, {10, 3}, {11, 3}, // cell 31000+ {12, 4}, {13, 4}, {14, 4}, // cell 41001+ {15, -1}, // out of bounds1002+ }1003+ for _, tt := range tests {1004+ got := localToCellX(tt.pos, 5)1005+ if got != tt.want {1006+ t.Errorf("localToCellX(%d, 5) = %d, want %d", tt.pos, got, tt.want)1007+ }1008+ }1009+ })1010+1011+ t.Run("10 cells with one spacer", func(t *testing.T) {1012+ // Cells 0-4, separator, cells 5-9.1013+ // Block width = 5*3+1 = 16.1014+ tests := []struct {1015+ pos int1016+ want int1017+ }{1018+ {0, 0}, // cell 0 start1019+ {2, 0}, // cell 0 end1020+ {3, 1}, // cell 1 start1021+ {12, 4}, // cell 4 start1022+ {14, 4}, // cell 4 end1023+ {15, -1}, // separator1024+ {16, 5}, // cell 5 start1025+ {18, 5}, // cell 5 end1026+ {19, 6}, // cell 6 start1027+ {28, 9}, // cell 9 start1028+ {30, 9}, // cell 9 end1029+ {31, -1}, // out of bounds1030+ }1031+ for _, tt := range tests {1032+ got := localToCellX(tt.pos, 10)1033+ if got != tt.want {1034+ t.Errorf("localToCellX(%d, 10) = %d, want %d", tt.pos, got, tt.want)1035+ }1036+ }1037+ })1038+1039+ t.Run("15 cells with two spacers", func(t *testing.T) {1040+ // Cells 0-4, sep, 5-9, sep, 10-14.1041+ // Total = 15*3 + 2 separators = 47 characters.1042+ tests := []struct {1043+ pos int1044+ want int1045+ }{1046+ {0, 0},1047+ {15, -1}, // first separator1048+ {16, 5}, // cell 51049+ {31, -1}, // second separator1050+ {32, 10}, // cell 101051+ {44, 14}, // cell 141052+ {46, 14}, // still cell 14 (chars 44-46)1053+ }1054+ for _, tt := range tests {1055+ got := localToCellX(tt.pos, 15)1056+ if got != tt.want {1057+ t.Errorf("localToCellX(%d, 15) = %d, want %d", tt.pos, got, tt.want)1058+ }1059+ }1060+ })1061+}1062+1063+func TestLocalToCellY(t *testing.T) {1064+ t.Run("small grid no spacers", func(t *testing.T) {1065+ for i := range 5 {1066+ got := localToCellY(i, 5)1067+ if got != i {1068+ t.Errorf("localToCellY(%d, 5) = %d, want %d", i, got, i)1069+ }1070+ }1071+ if got := localToCellY(5, 5); got != -1 {1072+ t.Errorf("localToCellY(5, 5) = %d, want -1", got)1073+ }1074+ })1075+1076+ t.Run("10 rows with one spacer", func(t *testing.T) {1077+ // Rows 0-4, separator at 5, rows 5-9 at positions 6-10.1078+ tests := []struct {1079+ pos int1080+ want int1081+ }{1082+ {0, 0},1083+ {4, 4},1084+ {5, -1}, // separator1085+ {6, 5},1086+ {10, 9},1087+ {11, -1}, // out of bounds1088+ }1089+ for _, tt := range tests {1090+ got := localToCellY(tt.pos, 10)1091+ if got != tt.want {1092+ t.Errorf("localToCellY(%d, 10) = %d, want %d", tt.pos, got, tt.want)1093+ }1094+ }1095+ })1096+}1097+1098+// --- screenToGrid (P0) ---1099+1100+func TestScreenToGrid(t *testing.T) {1101+ // Build a 5x5 model with known hint sizes and terminal dimensions.1102+ mode := NonogramMode{1103+ BaseMode: game.NewBaseMode("Test", "5x5"),1104+ Width: 5,1105+ Height: 5,1106+ }1107+ hints := Hints{1108+ rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1109+ cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1110+ }1111+ g, err := New(mode, hints)1112+ if err != nil {1113+ t.Fatal(err)1114+ }1115+ m := g.(Model)1116+ m.termWidth = 801117+ m.termHeight = 401118+1119+ ox, oy := m.gridOrigin()1120+1121+ t.Run("first cell", func(t *testing.T) {1122+ col, row, ok := m.screenToGrid(ox, oy)1123+ if !ok {1124+ t.Fatal("expected ok for first cell")1125+ }1126+ if col != 0 || row != 0 {1127+ t.Errorf("got (%d,%d), want (0,0)", col, row)1128+ }1129+ })1130+1131+ t.Run("last cell", func(t *testing.T) {1132+ col, row, ok := m.screenToGrid(ox+4*cellWidth, oy+4)1133+ if !ok {1134+ t.Fatal("expected ok for last cell")1135+ }1136+ if col != 4 || row != 4 {1137+ t.Errorf("got (%d,%d), want (4,4)", col, row)1138+ }1139+ })1140+1141+ t.Run("outside grid left", func(t *testing.T) {1142+ _, _, ok := m.screenToGrid(ox-1, oy)1143+ if ok {1144+ t.Error("expected not ok for click left of grid")1145+ }1146+ })1147+1148+ t.Run("outside grid above", func(t *testing.T) {1149+ _, _, ok := m.screenToGrid(ox, oy-1)1150+ if ok {1151+ t.Error("expected not ok for click above grid")1152+ }1153+ })1154+}@@ -4,47 +4,41 @@ import (
44 "fmt"55 "strings"66 7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"79 "github.com/FelineStateMachine/puzzletea/game"8- "github.com/charmbracelet/lipgloss"910 )1011 1112 var (12- baseStyle = lipgloss.NewStyle()13+ baseStyle = lipgloss.NewStyle()14+ backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}1315 1416 filledStyle = baseStyle.15- Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "222"}).16- Background(lipgloss.AdaptiveColor{Light: "223", Dark: "58"})17+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("222")}).18+ Background(compat.AdaptiveColor{Light: lipgloss.Color("223"), Dark: lipgloss.Color("58")})1719 1820 markedStyle = baseStyle.19- Foreground(lipgloss.AdaptiveColor{Light: "131", Dark: "173"}).20- Background(lipgloss.AdaptiveColor{Light: "224", Dark: "236"})21+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("131"), Dark: lipgloss.Color("173")}).22+ Background(compat.AdaptiveColor{Light: lipgloss.Color("224"), Dark: lipgloss.Color("236")})2123 2224 emptyStyle = baseStyle.23- Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"}).24- Background(lipgloss.AdaptiveColor{Light: "254", Dark: "235"})25-26- cursorStyle = baseStyle.27- Bold(true).28- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).29- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})25+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).26+ Background(backgroundColor)3027 28+ cursorStyle = game.CursorWarmStyle3129 cursorSolvedStyle = game.CursorSolvedStyle3230 33- crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}34- crosshairFilledBG = lipgloss.AdaptiveColor{Light: "223", Dark: "100"}35- solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}31+ crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}32+ crosshairFilledBG = compat.AdaptiveColor{Light: lipgloss.Color("223"), Dark: lipgloss.Color("100")}33+ solvedBG = compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")}3634 3735 hintStyle = baseStyle.38- Foreground(lipgloss.AdaptiveColor{Light: "137", Dark: "137"})36+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("137"), Dark: lipgloss.Color("137")})3937 4038 hintSatisfiedStyle = baseStyle.41- Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"})42-43- statusBarStyle = lipgloss.NewStyle().44- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).45- MarginTop(1)39+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")})4640 47- separatorFG = lipgloss.AdaptiveColor{Light: "250", Dark: "240"}41+ separatorFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}4842 )4943 5044 const (@@ -77,7 +71,7 @@ func needsSpacer(i, n int) bool {
7771 // hSeparator builds a horizontal separator row using box-drawing characters.7872 // w is the number of grid columns. cursorX is the cursor column (-1 to disable crosshair).7973 // bg is the default background, crossBG is the crosshair-highlighted background.80-func hSeparator(w, cursorX int, bg, crossBG lipgloss.TerminalColor) string {74+func hSeparator(w, cursorX int, bg, crossBG compat.AdaptiveColor) string {8175 defStyle := baseStyle.Foreground(separatorFG).Background(bg)8276 highStyle := baseStyle.Foreground(separatorFG).Background(crossBG)8377 segment := strings.Repeat("─", cellWidth)@@ -191,7 +185,7 @@ func gridView(g grid, c game.Cursor, solved bool) string {
191185 sepStyle := baseStyle.Foreground(separatorFG)192186 193187 // Determine background for horizontal separators (matches grid background).194- gridBG := emptyStyle.GetBackground()188+ gridBG := backgroundColor195189 if solved {196190 gridBG = solvedBG197191 }@@ -260,9 +254,9 @@ func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string
260254 261255 func statusBarView(showFullHelp bool) string {262256 if showFullHelp {263- return statusBarStyle.Render("arrows/wasd: move z: fill x: mark bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")257+ return game.StatusBarStyle.Render("arrows/wasd: move z: fill (hold+move) x: mark (hold+move) bkspc: clear LMB: fill RMB: mark ctrl+n: menu ctrl+r: reset ctrl+h: help")264258 }265- return statusBarStyle.Render("z: fill x: mark bkspc: clear")259+ return game.StatusBarStyle.Render("z: fill x: mark bkspc: clear mouse: click/drag")266260 }267261 268262 func intSliceEqual(a, b []int) bool {@@ -5,9 +5,9 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/game"77 8- "github.com/charmbracelet/bubbles/key"9- tea "github.com/charmbracelet/bubbletea"10- "github.com/charmbracelet/lipgloss"8+ "charm.land/bubbles/v2/key"9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"1111 )1212 1313 // expansion tracks how far the preview rectangle extends from the clue cell.@@ -56,11 +56,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5656 switch msg := msg.(type) {5757 case game.HelpToggleMsg:5858 m.showFullHelp = msg.Show59- case game.ResetMsg:60- m.puzzle.Rectangles = nil61- m.cursor = game.Cursor{}62- m.selectedClue = nil63- case tea.KeyMsg:59+ case tea.KeyPressMsg:6460 if m.puzzle.IsSolved() {6561 m.cursor.Move(m.keys.CursorKeyMap, msg, m.puzzle.Width-1, m.puzzle.Height-1)6662 } else if m.selectedClue != nil {@@ -72,7 +68,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7268 return m, nil7369 }7470 75-func (m Model) handleNavMode(msg tea.KeyMsg) Model {71+func (m Model) handleNavMode(msg tea.KeyPressMsg) Model {7672 switch {7773 case key.Matches(msg, m.keys.Select):7874 clue := m.puzzle.FindClueAt(m.cursor.X, m.cursor.Y)@@ -99,7 +95,7 @@ func (m Model) handleNavMode(msg tea.KeyMsg) Model {
9995 return m10096 }10197 102-func (m Model) handleExpansionMode(msg tea.KeyMsg) Model {98+func (m Model) handleExpansionMode(msg tea.KeyPressMsg) Model {10399 clue := m.puzzle.FindClueByID(*m.selectedClue)104100 if clue == nil {105101 m.selectedClue = nil@@ -1,8 +1,8 @@
11 package shikaku22 33 import (4+ "charm.land/bubbles/v2/key"45 "github.com/FelineStateMachine/puzzletea/game"5- "github.com/charmbracelet/bubbles/key"66 )77 88 // KeyMap defines the keybindings for Shikaku.@@ -37,7 +37,7 @@ var DefaultKeyMap = KeyMap{
3737 key.WithHelp("shift+→", "Shrink right"),3838 ),3939 Select: key.NewBinding(40- key.WithKeys("enter", " "),40+ key.WithKeys("enter", "space"),4141 key.WithHelp("enter/space", "Select/Confirm"),4242 ),4343 Cancel: key.NewBinding(@@ -4,57 +4,54 @@ import (
44 "fmt"55 "strings"66 7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"79 "github.com/FelineStateMachine/puzzletea/game"8- "github.com/charmbracelet/lipgloss"910 )1011 1112 const cellWidth = 31213 1314 // 16-color palette for placed rectangles (ANSI 256).14-var rectColors = []lipgloss.AdaptiveColor{15- {Dark: "60", Light: "60"}, // muted purple16- {Dark: "66", Light: "66"}, // teal17- {Dark: "95", Light: "95"}, // mauve18- {Dark: "130", Light: "130"}, // brown/amber19- {Dark: "71", Light: "71"}, // olive green20- {Dark: "132", Light: "132"}, // dusty rose21- {Dark: "37", Light: "37"}, // cyan22- {Dark: "136", Light: "136"}, // dark gold23- {Dark: "97", Light: "97"}, // plum24- {Dark: "29", Light: "29"}, // forest green25- {Dark: "167", Light: "167"}, // salmon26- {Dark: "68", Light: "68"}, // steel blue27- {Dark: "94", Light: "94"}, // rust28- {Dark: "139", Light: "139"}, // lavender29- {Dark: "28", Light: "28"}, // deep green30- {Dark: "133", Light: "133"}, // orchid15+var rectColors = []compat.AdaptiveColor{16+ {Dark: lipgloss.Color("60"), Light: lipgloss.Color("60")}, // muted purple17+ {Dark: lipgloss.Color("66"), Light: lipgloss.Color("66")}, // teal18+ {Dark: lipgloss.Color("95"), Light: lipgloss.Color("95")}, // mauve19+ {Dark: lipgloss.Color("130"), Light: lipgloss.Color("130")}, // brown/amber20+ {Dark: lipgloss.Color("71"), Light: lipgloss.Color("71")}, // olive green21+ {Dark: lipgloss.Color("132"), Light: lipgloss.Color("132")}, // dusty rose22+ {Dark: lipgloss.Color("37"), Light: lipgloss.Color("37")}, // cyan23+ {Dark: lipgloss.Color("136"), Light: lipgloss.Color("136")}, // dark gold24+ {Dark: lipgloss.Color("97"), Light: lipgloss.Color("97")}, // plum25+ {Dark: lipgloss.Color("29"), Light: lipgloss.Color("29")}, // forest green26+ {Dark: lipgloss.Color("167"), Light: lipgloss.Color("167")}, // salmon27+ {Dark: lipgloss.Color("68"), Light: lipgloss.Color("68")}, // steel blue28+ {Dark: lipgloss.Color("94"), Light: lipgloss.Color("94")}, // rust29+ {Dark: lipgloss.Color("139"), Light: lipgloss.Color("139")}, // lavender30+ {Dark: lipgloss.Color("28"), Light: lipgloss.Color("28")}, // deep green31+ {Dark: lipgloss.Color("133"), Light: lipgloss.Color("133")}, // orchid3132 }3233 3334 var (3435 baseStyle = lipgloss.NewStyle()3536 36- emptyDotColor = lipgloss.AdaptiveColor{Dark: "242", Light: "249"}37- clueColor = lipgloss.AdaptiveColor{Dark: "230", Light: "236"}38- clueBgColor = lipgloss.AdaptiveColor{Dark: "238", Light: "254"}37+ emptyDotColor = compat.AdaptiveColor{Dark: lipgloss.Color("242"), Light: lipgloss.Color("249")}38+ clueColor = compat.AdaptiveColor{Dark: lipgloss.Color("230"), Light: lipgloss.Color("236")}39+ clueBgColor = compat.AdaptiveColor{Dark: lipgloss.Color("238"), Light: lipgloss.Color("254")}3940 40- cursorBgColor = lipgloss.AdaptiveColor{Dark: "214", Light: "173"}41- cursorFgColor = lipgloss.AdaptiveColor{Dark: "235", Light: "255"}41+ cursorBgColor = compat.AdaptiveColor{Dark: lipgloss.Color("214"), Light: lipgloss.Color("173")}42+ cursorFgColor = compat.AdaptiveColor{Dark: lipgloss.Color("235"), Light: lipgloss.Color("255")}4243 43- previewGoodBg = lipgloss.AdaptiveColor{Dark: "28", Light: "157"}44- previewBadBg = lipgloss.AdaptiveColor{Dark: "124", Light: "217"}44+ previewGoodBg = compat.AdaptiveColor{Dark: lipgloss.Color("28"), Light: lipgloss.Color("157")}45+ previewBadBg = compat.AdaptiveColor{Dark: lipgloss.Color("124"), Light: lipgloss.Color("217")}4546 46- solvedBg = lipgloss.AdaptiveColor{Dark: "22", Light: "151"}47- solvedBorder = lipgloss.AdaptiveColor{Dark: "149", Light: "22"}48- borderColor = lipgloss.AdaptiveColor{Dark: "240", Light: "250"}47+ solvedBg = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("151")}48+ solvedBorder = compat.AdaptiveColor{Dark: lipgloss.Color("149"), Light: lipgloss.Color("22")}49+ borderColor = compat.AdaptiveColor{Dark: lipgloss.Color("240"), Light: lipgloss.Color("250")}4950 50- selectedClueBg = lipgloss.AdaptiveColor{Dark: "172", Light: "179"}51+ selectedClueBg = compat.AdaptiveColor{Dark: lipgloss.Color("172"), Light: lipgloss.Color("179")}5152 52- infoSatisfied = lipgloss.AdaptiveColor{Dark: "22", Light: "149"}53- infoText = lipgloss.AdaptiveColor{Dark: "137", Light: "137"}54-55- statusBarStyle = lipgloss.NewStyle().56- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).57- MarginTop(1)53+ infoSatisfied = compat.AdaptiveColor{Dark: lipgloss.Color("22"), Light: lipgloss.Color("149")}54+ infoText = compat.AdaptiveColor{Dark: lipgloss.Color("137"), Light: lipgloss.Color("137")}5855 5956 gridBorderStyle = lipgloss.NewStyle().6057 Border(lipgloss.RoundedBorder()).@@ -117,9 +114,9 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
117114 // Rectangle background color.118115 if owner >= 0 {119116 colorIdx := owner % len(rectColors)120- s = s.Background(rectColors[colorIdx]).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})117+ s = s.Background(rectColors[colorIdx]).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})121118 if clue != nil {122- s = s.Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"}).Bold(true)119+ s = s.Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")}).Bold(true)123120 }124121 }125122 @@ -127,22 +124,22 @@ func cellView(m Model, x, y int, solved bool, preview *Rectangle, previewClue *C
127124 inPreview := preview != nil && preview.Contains(x, y)128125 if inPreview && previewClue != nil {129126 if preview.Area() == previewClue.Value && !m.puzzle.Overlaps(*preview, previewClue.ID) {130- s = s.Background(previewGoodBg).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})127+ s = s.Background(previewGoodBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})131128 } else {132- s = s.Background(previewBadBg).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})129+ s = s.Background(previewBadBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})133130 }134131 }135132 136133 // Selected clue highlight.137134 if m.selectedClue != nil && clue != nil && clue.ID == *m.selectedClue && !inPreview {138- s = s.Background(selectedClueBg).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "235"}).Bold(true)135+ s = s.Background(selectedClueBg).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("235")}).Bold(true)139136 }140137 141138 // Solved styling.142139 if solved {143140 if owner >= 0 {144141 colorIdx := owner % len(rectColors)145- s = s.Background(rectColors[colorIdx]).Foreground(lipgloss.AdaptiveColor{Dark: "255", Light: "255"})142+ s = s.Background(rectColors[colorIdx]).Foreground(compat.AdaptiveColor{Dark: lipgloss.Color("255"), Light: lipgloss.Color("255")})146143 } else {147144 s = s.Background(solvedBg)148145 }@@ -180,12 +177,12 @@ func infoView(p *Puzzle) string {
180177 func statusBarView(selected, showFullHelp bool) string {181178 if selected {182179 if showFullHelp {183- return statusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")180+ return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")184181 }185- return statusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel")182+ return game.StatusBarStyle.Render("arrows: expand shift+arrows: shrink enter: confirm esc: cancel")186183 }187184 if showFullHelp {188- return statusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")185+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select clue bkspc: delete ctrl+n: menu ctrl+r: reset ctrl+h: help")189186 }190- return statusBarStyle.Render("enter/space: select clue bkspc: delete")187+ return game.StatusBarStyle.Render("enter/space: select clue bkspc: delete")191188 }@@ -9,7 +9,7 @@ import (
99 "github.com/FelineStateMachine/puzzletea/namegen"1010 "github.com/FelineStateMachine/puzzletea/store"1111 12- tea "github.com/charmbracelet/bubbletea"12+ tea "charm.land/bubbletea/v2"1313 )1414 1515 // spawnCmd returns a tea.Cmd that runs Spawn() off the main goroutine.@@ -63,6 +63,7 @@ func (m model) handleSpawnComplete(msg game.SpawnCompleteMsg) (tea.Model, tea.Cm
6363 m.game = msg.Game.SetTitle(name)6464 }6565 m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})66+ m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})6667 m.state = gameView6768 m.completionSaved = false6869 @@ -13,8 +13,9 @@ type exportCell struct {
1313 }1414 1515 type Save struct {16- Grid string `json:"grid"`17- Provided []exportCell `json:"provided"`16+ Grid string `json:"grid"`17+ Provided []exportCell `json:"provided"`18+ ModeTitle string `json:"mode_title,omitempty"`1819 }1920 2021 func gridToString(g grid) string {@@ -58,6 +59,8 @@ func ImportModel(data []byte) (*Model, error) {
5859 grid: g,5960 provided: provided,6061 providedGrid: buildProvidedGrid(provided),62+ conflicts: computeConflicts(g),63+ modeTitle: save.ModeTitle,6164 keys: DefaultKeyMap,6265 }, nil6366 }@@ -68,8 +71,9 @@ func (m Model) GetSave() ([]byte, error) {
6871 provided[i] = exportCell{X: c.x, Y: c.y, V: c.v}6972 }7073 save := Save{71- Grid: gridToString(m.grid),72- Provided: provided,74+ Grid: gridToString(m.grid),75+ Provided: provided,76+ ModeTitle: m.modeTitle,7377 }7478 jsonData, err := json.Marshal(save)7579 if err != nil {@@ -5,10 +5,10 @@ import (
55 "fmt"66 "strconv"77 8+ "charm.land/bubbles/v2/key"9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"811 "github.com/FelineStateMachine/puzzletea/game"9- "github.com/charmbracelet/bubbles/key"10- tea "github.com/charmbracelet/bubbletea"11- "github.com/charmbracelet/lipgloss"1212 )1313 1414 const gridSize = 9@@ -20,6 +20,7 @@ type Model struct {
2020 grid grid2121 provided []cell2222 providedGrid [gridSize][gridSize]bool23+ conflicts [gridSize][gridSize]bool2324 keys KeyMap2425 modeTitle string2526 showFullHelp bool@@ -39,6 +40,7 @@ func New(mode SudokuMode, provided []cell) (game.Gamer, error) {
3940 grid: g,4041 provided: provided,4142 providedGrid: buildProvidedGrid(provided),43+ conflicts: computeConflicts(g),4244 keys: DefaultKeyMap,4345 modeTitle: mode.Title(),4446 }@@ -55,7 +57,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5557 switch msg := msg.(type) {5658 case game.HelpToggleMsg:5759 m.showFullHelp = msg.Show58- case tea.KeyMsg:60+ case tea.KeyPressMsg:5961 switch {6062 case key.Matches(msg, m.keys.FillValue):6163 val, _ := strconv.Atoi(msg.String())@@ -83,6 +85,7 @@ func (m Model) IsSolved() bool {
8385 // Reset implements game.Gamer.8486 func (m Model) Reset() game.Gamer {8587 m.grid = newGrid(m.provided)88+ m.conflicts = computeConflicts(m.grid)8689 return m8790 }8891 @@ -91,14 +94,14 @@ func (m *Model) updateCell(v int) {
9194 return9295 }9396 m.grid[m.cursor.Y][m.cursor.X].v = v97+ m.conflicts = computeConflicts(m.grid)9498 }9599 96100 // View implements game.Gamer.97101 func (m Model) View() string {98- conflicts := computeConflicts(m.grid)99- solved := isSolvedWith(m.grid, conflicts)102+ solved := isSolvedWith(m.grid, m.conflicts)100103 title := game.TitleBarView("Sudoku", m.modeTitle, solved)101- grid := renderGrid(m, solved, conflicts)104+ grid := renderGrid(m, solved, m.conflicts)102105 status := statusBarView(m.showFullHelp)103106 104107 return lipgloss.JoinVertical(lipgloss.Center, title, grid, status)@@ -106,10 +109,9 @@ func (m Model) View() string {
106109 107110 // GetDebugInfo implements game.Gamer.108111 func (m Model) GetDebugInfo() string {109- conflicts := computeConflicts(m.grid)110112 cursorCell := m.grid[m.cursor.Y][m.cursor.X]111113 isProvided := m.providedGrid[m.cursor.Y][m.cursor.X]112- conflict := conflicts[m.cursor.Y][m.cursor.X]114+ conflict := m.conflicts[m.cursor.Y][m.cursor.X]113115 solved := m.isSolved()114116 115117 filledCount := 0@@ -119,7 +121,7 @@ func (m Model) GetDebugInfo() string {
119121 if m.grid[y][x].v != 0 {120122 filledCount++121123 }122- if conflicts[y][x] {124+ if m.conflicts[y][x] {123125 conflictCount++124126 }125127 }@@ -4,56 +4,51 @@ import (
44 "fmt"55 "strings"66 7- "github.com/charmbracelet/lipgloss"7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"9+ "github.com/FelineStateMachine/puzzletea/game"810 )911 1012 var (11- backgroundColor = lipgloss.AdaptiveColor{Light: "254", Dark: "235"}13+ backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}1214 1315 emptyCellStyle = lipgloss.NewStyle().14- Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"}).16+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).1517 Background(backgroundColor)1618 1719 providedCellStyle = lipgloss.NewStyle().1820 Bold(true).19- Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "179"}).21+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("179")}).2022 Background(backgroundColor)2123 2224 userCellStyle = lipgloss.NewStyle().23- Foreground(lipgloss.AdaptiveColor{Light: "236", Dark: "187"}).25+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("236"), Dark: lipgloss.Color("187")}).2426 Background(backgroundColor)2527 26- cursorCellStyle = lipgloss.NewStyle().27- Bold(true).28- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).29- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "173"})28+ cursorCellStyle = game.CursorStyle3029 3130 conflictCellStyle = lipgloss.NewStyle().32- Foreground(lipgloss.AdaptiveColor{Light: "160", Dark: "167"}).33- Background(lipgloss.AdaptiveColor{Light: "224", Dark: "52"})31+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("160"), Dark: lipgloss.Color("167")}).32+ Background(compat.AdaptiveColor{Light: lipgloss.Color("224"), Dark: lipgloss.Color("52")})3433 3534 sameNumberStyle = lipgloss.NewStyle().3635 Bold(true).37- Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "187"})36+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("187")})3837 39- crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}38+ crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}4039 41- boxBorderFG = lipgloss.AdaptiveColor{Light: "250", Dark: "240"}40+ boxBorderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}4241 4342 gridBorderStyle = lipgloss.NewStyle().4443 Border(lipgloss.RoundedBorder()).45- BorderForeground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"}).44+ BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).4645 BorderBackground(backgroundColor)4746 4847 gridBorderSolvedStyle = lipgloss.NewStyle().4948 Border(lipgloss.RoundedBorder()).50- BorderForeground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).49+ BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")}).5150 BorderBackground(backgroundColor)5251 53- statusBarStyle = lipgloss.NewStyle().54- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).55- MarginTop(1)56-5752 sudokuCellWidth = 25853 )5954 @@ -230,7 +225,7 @@ func computeConflicts(g grid) [gridSize][gridSize]bool {
230225 231226 func statusBarView(showFullHelp bool) string {232227 if showFullHelp {233- return statusBarStyle.Render("arrows/wasd: move 1-9: fill bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")228+ return game.StatusBarStyle.Render("arrows/wasd: move 1-9: fill bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")234229 }235- return statusBarStyle.Render("1-9: fill bkspc: clear")230+ return game.StatusBarStyle.Render("1-9: fill bkspc: clear")236231 }@@ -43,13 +43,23 @@ func ImportModel(data []byte) (*Model, error) {
4343 g := newGrid(state(s.State))4444 provided := deserializeProvided(s.Provided, s.Size)4545 46+ initial := g.clone()47+ for y := range s.Size {48+ for x := range s.Size {49+ if !provided[y][x] {50+ initial[y][x] = emptyCell51+ }52+ }53+ }54+4655 m := &Model{47- size: s.Size,48- grid: g,49- provided: provided,50- cursor: game.Cursor{X: 0, Y: 0},51- keys: DefaultKeyMap,52- modeTitle: s.ModeTitle,56+ size: s.Size,57+ grid: g,58+ initialGrid: initial,59+ provided: provided,60+ cursor: game.Cursor{X: 0, Y: 0},61+ keys: DefaultKeyMap,62+ modeTitle: s.ModeTitle,5363 }5464 m.solved = m.checkSolved()5565 return m, nil@@ -4,10 +4,10 @@ package takuzu
44 import (55 "fmt"66 7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"710 "github.com/FelineStateMachine/puzzletea/game"8- "github.com/charmbracelet/bubbles/key"9- tea "github.com/charmbracelet/bubbletea"10- "github.com/charmbracelet/lipgloss"1111 )1212 1313 // Model implements game.Gamer for Takuzu.@@ -48,7 +48,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
4848 switch msg := msg.(type) {4949 case game.HelpToggleMsg:5050 m.showFullHelp = msg.Show51- case tea.KeyMsg:51+ case tea.KeyPressMsg:5252 switch {5353 case key.Matches(msg, m.keys.PlaceZero):5454 if !m.provided[m.cursor.Y][m.cursor.X] && !m.solved {@@ -1,40 +1,32 @@
11 package takuzu22 33 import (4+ "charm.land/lipgloss/v2"5+ "charm.land/lipgloss/v2/compat"46 "github.com/FelineStateMachine/puzzletea/game"5- "github.com/charmbracelet/lipgloss"67 )78 89 var (910 baseStyle = lipgloss.NewStyle()10- backgroundColor = lipgloss.AdaptiveColor{Light: "254", Dark: "235"}11+ backgroundColor = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("235")}1112 zeroStyle = baseStyle.12- Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "222"}).13+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("222")}).1314 Background(backgroundColor)1415 1516 oneStyle = baseStyle.16- Foreground(lipgloss.AdaptiveColor{Light: "68", Dark: "111"}).17+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("68"), Dark: lipgloss.Color("111")}).1718 Background(backgroundColor)1819 1920 emptyStyle = baseStyle.20- Foreground(lipgloss.AdaptiveColor{Light: "250", Dark: "240"}).21+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}).2122 Background(backgroundColor)2223 23- cursorStyle = baseStyle.24- Bold(true).25- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).26- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "214"})27-2824 cursorSolvedStyle = game.CursorSolvedStyle2925 30- crosshairBG = lipgloss.AdaptiveColor{Light: "254", Dark: "237"}31- solvedBG = lipgloss.AdaptiveColor{Light: "151", Dark: "22"}32-33- borderFG = lipgloss.AdaptiveColor{Light: "250", Dark: "240"}26+ crosshairBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("237")}27+ solvedBG = compat.AdaptiveColor{Light: lipgloss.Color("151"), Dark: lipgloss.Color("22")}3428 35- statusBarStyle = lipgloss.NewStyle().36- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).37- MarginTop(1)29+ borderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}3830 )3931 4032 const cellWidth = 3@@ -66,7 +58,7 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
6658 if isCursor && solved {6759 s = cursorSolvedStyle6860 } else if isCursor {69- s = s.Background(cursorStyle.GetBackground()).Bold(true)61+ s = s.Background(game.CursorWarmBG).Bold(true)7062 } else if solved {7163 s = s.Background(solvedBG)7264 } else if inCursorRow || inCursorCol {@@ -92,7 +84,7 @@ func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {
9284 inCursorRow := y == c.Y9385 9486 // Left border segment for this row.95- rowCells = append(rowCells, borderChar("│", solved, !solved && inCursorRow))87+ rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))9688 9789 for x, val := range row {9890 isCursor := x == c.X && y == c.Y@@ -102,51 +94,29 @@ func gridView(g grid, provided [][]bool, c game.Cursor, solved bool) string {
10294 }10395 10496 // Right border segment for this row.105- rowCells = append(rowCells, borderChar("│", solved, !solved && inCursorRow))97+ rowCells = append(rowCells, game.BorderChar("│", gridBorderColors, solved, !solved && inCursorRow))10698 10799 rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, rowCells...))108100 }109101 110102 // Build top and bottom border rows with per-column crosshair highlighting.111- topRow := hBorderRow(w, c.X, "╭", "╮", solved)112- botRow := hBorderRow(w, c.X, "╰", "╯", solved)103+ topRow := game.HBorderRow(w, c.X, cellWidth, "╭", "╮", gridBorderColors, solved)104+ botRow := game.HBorderRow(w, c.X, cellWidth, "╰", "╯", gridBorderColors, solved)113105 114106 return lipgloss.JoinVertical(lipgloss.Left, topRow, lipgloss.JoinVertical(lipgloss.Left, rows...), botRow)115107 }116108 117-// borderChar renders a single border character with optional crosshair highlighting.118-func borderChar(ch string, solved, highlight bool) string {119- s := baseStyle.Foreground(borderFG).Background(backgroundColor)120- if solved {121- s = baseStyle.Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).Background(solvedBG)122- } else if highlight {123- s = s.Background(crosshairBG)124- }125- return s.Render(ch)126-}127-128-// hBorderRow builds a horizontal border row (top or bottom) with per-column crosshair.129-func hBorderRow(w, cursorX int, left, right string, solved bool) string {130- var parts []string131- parts = append(parts, borderChar(left, solved, false))132- segment := "───" // cellWidth = 3133- for x := range w {134- highlight := !solved && x == cursorX135- s := baseStyle.Foreground(borderFG).Background(backgroundColor)136- if solved {137- s = baseStyle.Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "149"}).Background(solvedBG)138- } else if highlight {139- s = s.Background(crosshairBG)140- }141- parts = append(parts, s.Render(segment))142- }143- parts = append(parts, borderChar(right, solved, false))144- return lipgloss.JoinHorizontal(lipgloss.Top, parts...)109+var gridBorderColors = game.GridBorderColors{110+ BorderFG: borderFG,111+ BackgroundBG: backgroundColor,112+ CrosshairBG: crosshairBG,113+ SolvedBorderFG: compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")},114+ SolvedBG: solvedBG,145115 }146116 147117 func statusBarView(showFullHelp bool) string {148118 if showFullHelp {149- return statusBarStyle.Render("arrows/wasd: move z: ● x: ○ bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")119+ return game.StatusBarStyle.Render("arrows/wasd: move z: ● x: ○ bkspc: clear ctrl+n: menu ctrl+r: reset ctrl+h: help")150120 }151- return statusBarStyle.Render("z: ● x: ○ bkspc: clear")121+ return game.StatusBarStyle.Render("z: ● x: ○ bkspc: clear")152122 }@@ -1,8 +1,9 @@
11 package ui22 33 import (4- "github.com/charmbracelet/bubbles/list"5- "github.com/charmbracelet/lipgloss"4+ "charm.land/bubbles/v2/list"5+ "charm.land/lipgloss/v2"6+ "charm.land/lipgloss/v2/compat"67 )78 89 // InitList creates a styled list widget with the standard puzzletea theme.@@ -23,11 +24,27 @@ func InitList(items []list.Item, title string) list.Model {
2324 l.Title = title2425 l.Styles.Title = lipgloss.NewStyle().2526 Bold(true).26- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).27+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("255"), Dark: lipgloss.Color("255")}).2728 Background(MenuAccent).2829 Padding(0, 1)2930 l.DisableQuitKeybindings()3031 l.SetFilteringEnabled(false)3132 l.SetShowHelp(false)33+ l.SetShowStatusBar(false)34+ l.SetShowPagination(false)3235 return l3336 }37+38+// ListHeight returns the minimum height needed to display all items in a39+// single page, avoiding pagination. The bubbles list component has a40+// circular dependency between pagination visibility and PerPage calculation41+// that causes rendering glitches when pages change, so we size the list to42+// prevent pagination entirely when the terminal is tall enough.43+func ListHeight(l list.Model) int {44+ n := len(l.Items())45+ // title bar (title line + bottom padding) = 2 lines46+ const titleHeight = 247+ // Default delegate: height=2, spacing=1 → 3 lines per item.48+ const itemSlot = 349+ return titleHeight + itemSlot*n50+}@@ -1,18 +1,25 @@
11 package ui22 3-import "github.com/charmbracelet/lipgloss"3+import (4+ "charm.land/lipgloss/v2"5+ "charm.land/lipgloss/v2/compat"6+)47 58 // Earth-tone palette for menus — ANSI 256 colors with light/dark adaptivity.69 var (7- MenuAccent = lipgloss.AdaptiveColor{Light: "130", Dark: "173"}8- MenuAccentLight = lipgloss.AdaptiveColor{Light: "137", Dark: "180"}9- MenuText = lipgloss.AdaptiveColor{Light: "235", Dark: "252"}10- MenuTextDim = lipgloss.AdaptiveColor{Light: "243", Dark: "243"}11- MenuDim = lipgloss.AdaptiveColor{Light: "250", Dark: "238"}12- MenuTableHeader = lipgloss.AdaptiveColor{Light: "130", Dark: "180"}10+ MenuAccent = compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("173")}11+ MenuAccentLight = compat.AdaptiveColor{Light: lipgloss.Color("137"), Dark: lipgloss.Color("180")}12+ MenuText = compat.AdaptiveColor{Light: lipgloss.Color("235"), Dark: lipgloss.Color("252")}13+ MenuTextDim = compat.AdaptiveColor{Light: lipgloss.Color("243"), Dark: lipgloss.Color("243")}14+ MenuDim = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("238")}15+ MenuTableHeader = compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("180")}1316 14- RootStyle = lipgloss.NewStyle().Margin(1, 2)1517 DebugStyle = lipgloss.NewStyle().1618 Border(lipgloss.RoundedBorder(), true).17- BorderForeground(lipgloss.Color("124"))19+ BorderForeground(compat.AdaptiveColor{Light: lipgloss.Color("124"), Dark: lipgloss.Color("124")})1820 )21+22+// CenterView wraps content in centered placement within the available area.23+func CenterView(width, height int, content string) string {24+ return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)25+}@@ -3,9 +3,10 @@ package ui
33 import (44 "log"55 6+ "charm.land/bubbles/v2/table"7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"69 "github.com/FelineStateMachine/puzzletea/store"7- "github.com/charmbracelet/bubbles/table"8- "github.com/charmbracelet/lipgloss"910 )1011 1112 // FormatStatus converts a GameStatus enum to a human-readable display string.@@ -50,10 +51,18 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
5051 }5152 }5253 54+ tableWidth := 055+ for _, c := range columns {56+ tableWidth += c.Width57+ }58+ // Account for column gaps (2 chars between each column).59+ tableWidth += (len(columns) - 1) * 260+5361 t := table.New(5462 table.WithColumns(columns),5563 table.WithRows(rows),5664 table.WithFocused(true),65+ table.WithWidth(tableWidth),5766 table.WithHeight(max(height-2, 1)),5867 )5968 @@ -65,7 +74,7 @@ func InitContinueTable(s *store.Store, height int) (table.Model, []store.GameRec
6574 BorderBottom(true).6675 BorderForeground(MenuDim)6776 st.Selected = st.Selected.68- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).77+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("255"), Dark: lipgloss.Color("255")}).6978 Background(MenuAccent).7079 Bold(true)7180 st.Cell = st.Cell.@@ -9,8 +9,9 @@ import (
99 "github.com/FelineStateMachine/puzzletea/store"1010 "github.com/FelineStateMachine/puzzletea/ui"1111 12- "github.com/charmbracelet/bubbles/viewport"13- tea "github.com/charmbracelet/bubbletea"12+ "charm.land/bubbles/v2/key"13+ "charm.land/bubbles/v2/viewport"14+ tea "charm.land/bubbletea/v2"1415 "github.com/charmbracelet/glamour"1516 )1617 @@ -22,33 +23,31 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
2223 return m.handleSpawnComplete(msg)2324 2425 case tea.WindowSizeMsg:25- h, v := ui.RootStyle.GetFrameSize()26- w, ht := msg.Width-h, msg.Height-v27- m.width = w28- m.height = ht29- menuW, menuH := min(w, 64), min(ht, 24)30- m.mainMenuList.SetSize(menuW, menuH)31- m.gameSelectList.SetSize(menuW, menuH)26+ m.width = msg.Width27+ m.height = msg.Height28+ menuW := min(m.width, 64)29+ m.mainMenuList.SetSize(menuW, min(m.height, ui.ListHeight(m.mainMenuList)))30+ m.gameSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.gameSelectList)))3231 if m.state == modeSelectView {33- m.modeSelectList.SetSize(menuW, menuH)32+ m.modeSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.modeSelectList)))3433 }3534 if m.state == continueView {36- m.continueTable.SetWidth(w)37- m.continueTable.SetHeight(ht)35+ m.continueTable.SetWidth(m.width)36+ m.continueTable.SetHeight(m.height)3837 }3938 if m.state == helpSelectView {40- m.helpSelectList.SetSize(menuW, menuH)39+ m.helpSelectList.SetSize(menuW, min(m.height, ui.ListHeight(m.helpSelectList)))4140 }4241 if m.state == helpDetailView {43- m.helpViewport.Width = w44- m.helpViewport.Height = ht - 242+ m.helpViewport.SetWidth(m.width)43+ m.helpViewport.SetHeight(m.height - 2)4544 }4645 47- case tea.KeyMsg:46+ case tea.KeyPressMsg:4847 // During generation, only allow Escape (to cancel) and Ctrl+C (to quit).4948 if m.state == generatingView {50- switch msg.Type {51- case tea.KeyEscape:49+ switch {50+ case key.Matches(msg, rootKeys.Escape):5251 if m.dailyPending {5352 m.dailyPending = false5453 m.state = mainMenuView@@ -56,33 +55,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
5655 m.state = modeSelectView5756 }5857 return m, nil59- case tea.KeyCtrlC:58+ case key.Matches(msg, rootKeys.Quit):6059 return m, tea.Quit6160 }6261 return m, nil6362 }64- switch msg.Type {65- case tea.KeyCtrlN:63+ switch {64+ case key.Matches(msg, rootKeys.MainMenu):6665 m = saveCurrentGame(m, store.StatusInProgress)6766 m.state = mainMenuView6867 m.debug = false69- case tea.KeyEnter:68+ case key.Matches(msg, rootKeys.Enter):7069 if m.state != gameView {7170 return m.handleEnter()7271 }73- case tea.KeyEscape:72+ case key.Matches(msg, rootKeys.Escape):7473 return m.handleEscape()75- case tea.KeyCtrlC:74+ case key.Matches(msg, rootKeys.Quit):7675 m = saveCurrentGame(m, store.StatusAbandoned)7776 return m, tea.Quit78- case tea.KeyCtrlE:77+ case key.Matches(msg, rootKeys.Debug):7978 m.debug = !m.debug80- case tea.KeyCtrlH:79+ case key.Matches(msg, rootKeys.FullHelp):8180 m.showFullHelp = !m.showFullHelp8281 if m.state == gameView && m.game != nil {8382 m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})8483 }85- case tea.KeyCtrlR:84+ case key.Matches(msg, rootKeys.ResetGame):8685 if m.state == gameView && m.game != nil {8786 m.game = m.game.Reset()8887 }@@ -153,7 +152,7 @@ func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {
153152 m.state = continueView154153 case "Guides":155154 m.helpSelectList = ui.InitList(GameCategories, "How to Play")156- m.helpSelectList.SetSize(min(m.width, 64), min(m.height, 24))155+ m.helpSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.helpSelectList)))157156 m.state = helpSelectView158157 case "Quit":159158 return m, tea.Quit@@ -187,6 +186,7 @@ func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {
187186 }188187 m.game = g.SetTitle(rec.Name)189188 m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})189+ m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})190190 m.activeGameID = rec.ID191191 m.state = gameView192192 m.completionSaved = rec.Status == store.StatusCompleted@@ -216,7 +216,7 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
216216 }217217 m.selectedCategory = cat218218 m.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")219- m.modeSelectList.SetSize(m.gameSelectList.Width(), m.gameSelectList.Height())219+ m.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.modeSelectList)))220220 m.state = modeSelectView221221 return m, nil222222 }@@ -249,6 +249,7 @@ func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {
249249 }250250 m.game = g.SetTitle(rec.Name)251251 m.game, _ = m.game.Update(game.HelpToggleMsg{Show: m.showFullHelp})252+ m.game, _ = m.game.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})252253 m.activeGameID = rec.ID253254 m.state = gameView254255 m.completionSaved = rec.Status == store.StatusCompleted@@ -280,7 +281,7 @@ func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {
280281 return m, nil281282 }282283 283- m.helpViewport = viewport.New(m.width, m.height-2)284+ m.helpViewport = viewport.New(viewport.WithWidth(m.width), viewport.WithHeight(m.height-2))284285 m.helpViewport.SetContent(rendered)285286 m.state = helpDetailView286287 return m, nil@@ -5,20 +5,24 @@ import (
55 66 "github.com/FelineStateMachine/puzzletea/ui"77 8- "github.com/charmbracelet/lipgloss"8+ tea "charm.land/bubbletea/v2"9+ "charm.land/lipgloss/v2"10+ "charm.land/lipgloss/v2/compat"911 )1012 11-func (m model) View() string {13+func (m model) View() tea.View {14+ var content string15+1216 switch m.state {1317 case mainMenuView:14- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.mainMenuList.View()))18+ content = ui.CenterView(m.width, m.height, m.mainMenuList.View())1519 case gameSelectView:16- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.gameSelectList.View()))20+ content = ui.CenterView(m.width, m.height, m.gameSelectList.View())1721 case modeSelectView:18- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.modeSelectList.View()))22+ content = ui.CenterView(m.width, m.height, m.modeSelectList.View())1923 case generatingView:2024 s := m.spinner.View() + " Generating puzzle..."21- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))25+ content = ui.CenterView(m.width, m.height, s)2226 case continueView:2327 var s string2428 if len(m.continueGames) == 0 {@@ -26,28 +30,29 @@ func (m model) View() string {
2630 } else {2731 title := lipgloss.NewStyle().2832 Bold(true).29- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "255"}).33+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("255"), Dark: lipgloss.Color("255")}).3034 Background(ui.MenuAccent).3135 Padding(0, 1).3236 Render("Saved Games")3337 s = lipgloss.JoinVertical(lipgloss.Left, title, "", m.continueTable.View())3438 }35- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))39+ content = ui.CenterView(m.width, m.height, s)3640 case gameView:3741 if m.game == nil {38- return ""39- }40- var debugInfo string41- if m.debug {42- debugInfo = ui.DebugStyle.Render(m.debuginfo)42+ content = ""43+ } else {44+ gameView := lipgloss.NewStyle().MaxWidth(m.width).Render(m.game.View())45+ centered := gameView46+ if m.debug {47+ debugInfo := lipgloss.NewStyle().MaxWidth(m.width).Render(48+ ui.DebugStyle.Render(m.debuginfo),49+ )50+ centered = lipgloss.JoinVertical(lipgloss.Center, gameView, debugInfo)51+ }52+ content = ui.CenterView(m.width, m.height, centered)4353 }44- s := lipgloss.JoinVertical(lipgloss.Center,45- m.game.View(),46- debugInfo,47- )48- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))4954 case helpSelectView:50- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.helpSelectList.View()))55+ content = ui.CenterView(m.width, m.height, m.helpSelectList.View())5156 case helpDetailView:5257 footer := lipgloss.NewStyle().5358 Foreground(ui.MenuTextDim).@@ -56,9 +61,16 @@ func (m model) View() string {
5661 m.helpViewport.View(),5762 footer,5863 )59- return ui.RootStyle.Render(lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, s))64+ content = ui.CenterView(m.width, m.height, s)6065 default:61- s := fmt.Sprintf("unknown state: %d", m.state)62- panic(s)66+ content = fmt.Sprintf("unknown state: %d", m.state)67+ }68+69+ v := tea.NewView(content)70+ v.AltScreen = true71+ if m.state == gameView {72+ v.MouseMode = tea.MouseModeCellMotion73+ v.KeyboardEnhancements.ReportEventTypes = true6374 }75+ return v6476 }@@ -5,9 +5,9 @@ import (
55 "fmt"66 "strings"77 8+ "charm.land/bubbles/v2/key"9+ tea "charm.land/bubbletea/v2"810 "github.com/FelineStateMachine/puzzletea/game"9- "github.com/charmbracelet/bubbles/key"10- tea "github.com/charmbracelet/bubbletea"1111 )1212 1313 type selectionState int@@ -71,13 +71,13 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
7171 switch msg := msg.(type) {7272 case game.HelpToggleMsg:7373 m.showFullHelp = msg.Show74- case tea.KeyMsg:74+ case tea.KeyPressMsg:7575 return m.handleKeyPress(msg)7676 }7777 return m, nil7878 }7979 80-func (m Model) handleKeyPress(msg tea.KeyMsg) (game.Gamer, tea.Cmd) {80+func (m Model) handleKeyPress(msg tea.KeyPressMsg) (game.Gamer, tea.Cmd) {8181 switch {8282 case key.Matches(msg, m.keys.Select):8383 m.handleSelect()@@ -1,8 +1,8 @@
11 package wordsearch22 33 import (4+ "charm.land/bubbles/v2/key"45 "github.com/FelineStateMachine/puzzletea/game"5- "github.com/charmbracelet/bubbles/key"66 )77 88 type KeyMap struct {@@ -14,7 +14,7 @@ type KeyMap struct {
1414 var DefaultKeyMap = KeyMap{1515 CursorKeyMap: game.DefaultCursorKeyMap,1616 Select: key.NewBinding(17- key.WithKeys("enter", " "),17+ key.WithKeys("enter", "space"),1818 key.WithHelp("enter/space", "Select"),1919 ),2020 Cancel: key.NewBinding(@@ -4,46 +4,41 @@ import (
44 "fmt"55 "strings"66 7+ "charm.land/lipgloss/v2"8+ "charm.land/lipgloss/v2/compat"79 "github.com/FelineStateMachine/puzzletea/game"8- "github.com/charmbracelet/lipgloss"910 )1011 1112 var (1213 normalStyle = lipgloss.NewStyle().13- Foreground(lipgloss.AdaptiveColor{Light: "236", Dark: "252"}).14- Background(lipgloss.AdaptiveColor{Light: "254", Dark: "236"})14+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("236"), Dark: lipgloss.Color("252")}).15+ Background(compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("236")})1516 16- cursorStyle = lipgloss.NewStyle().17- Foreground(lipgloss.AdaptiveColor{Light: "255", Dark: "235"}).18- Background(lipgloss.AdaptiveColor{Light: "130", Dark: "173"})17+ cursorStyle = game.CursorStyle1918 2019 selectionStyle = lipgloss.NewStyle().21- Foreground(lipgloss.AdaptiveColor{Light: "235", Dark: "235"}).22- Background(lipgloss.AdaptiveColor{Light: "172", Dark: "180"})20+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("235"), Dark: lipgloss.Color("235")}).21+ Background(compat.AdaptiveColor{Light: lipgloss.Color("172"), Dark: lipgloss.Color("180")})2322 2423 foundStyle = lipgloss.NewStyle().25- Foreground(lipgloss.AdaptiveColor{Light: "22", Dark: "151"}).26- Background(lipgloss.AdaptiveColor{Light: "194", Dark: "237"})24+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("151")}).25+ Background(compat.AdaptiveColor{Light: lipgloss.Color("194"), Dark: lipgloss.Color("237")})2726 2827 wordListHeaderStyle = lipgloss.NewStyle().29- Foreground(lipgloss.AdaptiveColor{Light: "130", Dark: "173"}).28+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("130"), Dark: lipgloss.Color("173")}).3029 Bold(true).3130 Underline(true)3231 3332 foundWordStyle = lipgloss.NewStyle().34- Foreground(lipgloss.AdaptiveColor{Light: "28", Dark: "115"}).33+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("28"), Dark: lipgloss.Color("115")}).3534 Strikethrough(true)3635 3736 unfoundWordStyle = lipgloss.NewStyle().38- Foreground(lipgloss.AdaptiveColor{Light: "137", Dark: "137"})37+ Foreground(compat.AdaptiveColor{Light: lipgloss.Color("137"), Dark: lipgloss.Color("137")})3938 40- statusBarStyle = lipgloss.NewStyle().41- Foreground(lipgloss.AdaptiveColor{Light: "244", Dark: "244"}).42- MarginTop(1)43-44- borderFG = lipgloss.AdaptiveColor{Light: "250", Dark: "240"}45- gridBG = lipgloss.AdaptiveColor{Light: "254", Dark: "236"}46- solvedBdrFG = lipgloss.AdaptiveColor{Light: "22", Dark: "149"}39+ borderFG = compat.AdaptiveColor{Light: lipgloss.Color("250"), Dark: lipgloss.Color("240")}40+ gridBG = compat.AdaptiveColor{Light: lipgloss.Color("254"), Dark: lipgloss.Color("236")}41+ solvedBdrFG = compat.AdaptiveColor{Light: lipgloss.Color("22"), Dark: lipgloss.Color("149")}4742 4843 gridBorderStyle = lipgloss.NewStyle().4944 Border(lipgloss.RoundedBorder()).@@ -94,9 +89,9 @@ func renderView(m Model) string {
9489 9590 func statusBarView(showFullHelp bool) string {9691 if showFullHelp {97- return statusBarStyle.Render("arrows/wasd: move enter/space: select esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")92+ return game.StatusBarStyle.Render("arrows/wasd: move enter/space: select esc: cancel ctrl+n: menu ctrl+r: reset ctrl+h: help")9893 }99- return statusBarStyle.Render("enter/space: select esc: cancel")94+ return game.StatusBarStyle.Render("enter/space: select esc: cancel")10095 }10196 10297 func renderGrid(m Model) string {