clone

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

added markdown theme support. added sudokurgb. updated hashi to use dynamicgrid

Dami committed 35d88ce
commit
35d88ce1dba67c31115ba0cf8f250591bbcc3e78
parent
428ce97e524cfa92d621feb9a9ce8dddce4653bd
author
Dami <dami.etoile@gmail.com>
42 files changed, +3540 -330
Aapp/markdown_style.go196++++++++++++
Aapp/markdown_style_test.go92++++++
Aapp/mode_display.go121+++++++
Aapp/mode_display_test.go96++++++
Mapp/model.go1+
Mapp/update.go13+-
Mapp/view.go11+-
Mcatalog/catalog.go2+
Mcatalog/catalog_test.go2+
Mdaily/daily_test.go15+
Mfillomino/Model.go8+-
Mfillomino/fillomino_test.go57+++
Mfillomino/style.go41+++
Mgame/dynamic_grid.go72++++-
Mgame/dynamic_grid_test.go28++
Mhashiwokakero/Model.go29+-
Mhashiwokakero/hashiwokakero_test.go280+++++++++++++++++
Ahashiwokakero/mouse.go37++
Mhashiwokakero/style.go491++++++++++++++++++------------
Mpdfexport/jsonl_test.go43+++
Mpdfexport/printdata.go24+
Mpdfexport/printdata_test.go21+
Mresolve/resolve_test.go18+
Msudoku/Model.go25+-
Msudoku/README.md1+
Asudoku/dynamic_board_test.go410+++++++++++++++++++++++++
Msudoku/help.md1+
Asudoku/mouse.go33++
Msudoku/style.go248++++++++-------
Asudokurgb/Export.go81+++++
Asudokurgb/Gamemode.go62++++
Asudokurgb/Model.go175+++++++++++
Asudokurgb/PrintAdapter.go114+++++++
Asudokurgb/README.md52+++
Asudokurgb/cell.go103++++++
Asudokurgb/generator.go338+++++++++++++++++++++
Asudokurgb/help.md35++
Asudokurgb/keys.go46+++
Asudokurgb/mouse.go33++
Asudokurgb/style.go224++++++++++++++
Asudokurgb/sudokurgb_test.go176+++++++++++
Mweekly/weekly_test.go15+
A app/markdown_style.go +196 -0
@@ -0,0 +1,196 @@
1+package app2+3+import (4+ "fmt"5+ "image/color"6+7+ "github.com/FelineStateMachine/puzzletea/theme"8+ "github.com/charmbracelet/glamour/ansi"9+)10+11+func helpMarkdownStyle(p theme.Palette) ansi.StyleConfig {12+ return ansi.StyleConfig{13+ Document: ansi.StyleBlock{14+ StylePrimitive: ansi.StylePrimitive{15+ Color: colorPtr(p.FG),16+ },17+ },18+ BlockQuote: ansi.StyleBlock{19+ StylePrimitive: ansi.StylePrimitive{20+ Color: colorPtr(p.TextDim),21+ },22+ Indent: uintPtr(1),23+ IndentToken: stringPtr("│ "),24+ Margin: uintPtr(1),25+ },26+ Paragraph: ansi.StyleBlock{27+ StylePrimitive: ansi.StylePrimitive{28+ Color: colorPtr(p.FG),29+ },30+ Margin: uintPtr(1),31+ },32+ List: ansi.StyleList{33+ StyleBlock: ansi.StyleBlock{34+ StylePrimitive: ansi.StylePrimitive{35+ Color: colorPtr(p.FG),36+ },37+ Margin: uintPtr(1),38+ },39+ LevelIndent: 2,40+ },41+ Heading: ansi.StyleBlock{42+ StylePrimitive: ansi.StylePrimitive{43+ Color: colorPtr(p.Accent),44+ Bold: boolPtr(true),45+ },46+ Margin: uintPtr(1),47+ },48+ H1: ansi.StyleBlock{49+ StylePrimitive: ansi.StylePrimitive{50+ Color: colorPtr(p.Accent),51+ Bold: boolPtr(true),52+ },53+ Margin: uintPtr(1),54+ },55+ H2: ansi.StyleBlock{56+ StylePrimitive: ansi.StylePrimitive{57+ Color: colorPtr(p.AccentSoft),58+ Bold: boolPtr(true),59+ },60+ Margin: uintPtr(1),61+ },62+ H3: ansi.StyleBlock{63+ StylePrimitive: ansi.StylePrimitive{64+ Color: colorPtr(p.Info),65+ Bold: boolPtr(true),66+ },67+ Margin: uintPtr(1),68+ },69+ H4: ansi.StyleBlock{70+ StylePrimitive: ansi.StylePrimitive{71+ Color: colorPtr(p.Secondary),72+ Bold: boolPtr(true),73+ },74+ },75+ H5: ansi.StyleBlock{76+ StylePrimitive: ansi.StylePrimitive{77+ Color: colorPtr(p.Linked),78+ Bold: boolPtr(true),79+ },80+ },81+ H6: ansi.StyleBlock{82+ StylePrimitive: ansi.StylePrimitive{83+ Color: colorPtr(p.TextDim),84+ Bold: boolPtr(true),85+ },86+ },87+ Text: ansi.StylePrimitive{88+ Color: colorPtr(p.FG),89+ },90+ Emph: ansi.StylePrimitive{91+ Color: colorPtr(p.Secondary),92+ Italic: boolPtr(true),93+ },94+ Strong: ansi.StylePrimitive{95+ Color: colorPtr(p.AccentSoft),96+ Bold: boolPtr(true),97+ },98+ HorizontalRule: ansi.StylePrimitive{99+ Color: colorPtr(p.Border),100+ },101+ Item: ansi.StylePrimitive{102+ Color: colorPtr(p.Accent),103+ },104+ Enumeration: ansi.StylePrimitive{105+ Color: colorPtr(p.Accent),106+ },107+ Task: ansi.StyleTask{108+ StylePrimitive: ansi.StylePrimitive{109+ Color: colorPtr(p.Accent),110+ },111+ Ticked: "[x]",112+ Unticked: "[ ]",113+ },114+ Link: ansi.StylePrimitive{115+ Color: colorPtr(p.Highlight),116+ Underline: boolPtr(true),117+ },118+ LinkText: ansi.StylePrimitive{119+ Color: colorPtr(p.Highlight),120+ Underline: boolPtr(true),121+ },122+ Code: ansi.StyleBlock{123+ StylePrimitive: ansi.StylePrimitive{124+ Color: colorPtr(p.AccentSoft),125+ BackgroundColor: colorPtr(p.Surface),126+ },127+ },128+ CodeBlock: ansi.StyleCodeBlock{129+ StyleBlock: ansi.StyleBlock{130+ StylePrimitive: ansi.StylePrimitive{131+ Color: colorPtr(p.FG),132+ BackgroundColor: colorPtr(p.Surface),133+ },134+ Margin: uintPtr(1),135+ },136+ },137+ Table: ansi.StyleTable{138+ StyleBlock: ansi.StyleBlock{139+ StylePrimitive: ansi.StylePrimitive{140+ Color: colorPtr(p.FG),141+ },142+ Margin: uintPtr(1),143+ },144+ CenterSeparator: stringPtr("┼"),145+ ColumnSeparator: stringPtr("│"),146+ RowSeparator: stringPtr("─"),147+ },148+ DefinitionTerm: ansi.StylePrimitive{149+ Color: colorPtr(p.Accent),150+ Bold: boolPtr(true),151+ },152+ DefinitionDescription: ansi.StylePrimitive{153+ Color: colorPtr(p.FG),154+ },155+ Strikethrough: ansi.StylePrimitive{156+ Color: colorPtr(p.TextDim),157+ },158+ }159+}160+161+func helpMarkdownThemeKey(p theme.Palette) string {162+ return fmt.Sprintf(163+ "%s:%s:%s:%s:%s:%s:%s:%s:%s",164+ hexColor(p.BG),165+ hexColor(p.FG),166+ hexColor(p.Surface),167+ hexColor(p.Accent),168+ hexColor(p.AccentSoft),169+ hexColor(p.Info),170+ hexColor(p.Highlight),171+ hexColor(p.Border),172+ hexColor(p.TextDim),173+ )174+}175+176+func hexColor(c color.Color) string {177+ r, g, b, _ := c.RGBA()178+ return fmt.Sprintf("#%02x%02x%02x", uint8(r>>8), uint8(g>>8), uint8(b>>8))179+}180+181+func colorPtr(c color.Color) *string {182+ s := hexColor(c)183+ return &s184+}185+186+func boolPtr(v bool) *bool {187+ return &v188+}189+190+func stringPtr(v string) *string {191+ return &v192+}193+194+func uintPtr(v uint) *uint {195+ return &v196+}
A app/markdown_style_test.go +92 -0
@@ -0,0 +1,92 @@
1+package app2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/theme"8+)9+10+func TestHelpMarkdownStyleUsesPaletteColors(t *testing.T) {11+ if err := theme.Apply(""); err != nil {12+ t.Fatalf("apply default theme: %v", err)13+ }14+ t.Cleanup(func() { _ = theme.Apply("") })15+16+ p := theme.Current()17+ style := helpMarkdownStyle(p)18+19+ if got := *style.H1.Color; got != hexColor(p.Accent) {20+ t.Fatalf("H1 color = %q, want %q", got, hexColor(p.Accent))21+ }22+ if got := *style.Code.StylePrimitive.BackgroundColor; got != hexColor(p.Surface) {23+ t.Fatalf("inline code background = %q, want %q", got, hexColor(p.Surface))24+ }25+ if got := *style.Link.Color; got != hexColor(p.Highlight) {26+ t.Fatalf("link color = %q, want %q", got, hexColor(p.Highlight))27+ }28+}29+30+func TestHelpMarkdownThemeKeyChangesWithTheme(t *testing.T) {31+ if err := theme.Apply(""); err != nil {32+ t.Fatalf("apply default theme: %v", err)33+ }34+ t.Cleanup(func() { _ = theme.Apply("") })35+36+ defaultKey := helpMarkdownThemeKey(theme.Current())37+38+ if err := theme.Apply("Dracula"); err != nil {39+ t.Fatalf("apply Dracula theme: %v", err)40+ }41+42+ draculaKey := helpMarkdownThemeKey(theme.Current())43+ if draculaKey == defaultKey {44+ t.Fatalf("theme key did not change across themes: %q", draculaKey)45+ }46+}47+48+func TestUpdateHelpDetailViewportRebuildsRendererAfterThemeChange(t *testing.T) {49+ if err := theme.Apply(""); err != nil {50+ t.Fatalf("apply default theme: %v", err)51+ }52+ t.Cleanup(func() { _ = theme.Apply("") })53+54+ m := model{55+ width: 100,56+ height: 30,57+ help: helpState{58+ category: game.Category{59+ Name: "Nonogram",60+ Help: "# Nonogram\n\n- clue\n",61+ },62+ },63+ }64+65+ m = m.updateHelpDetailViewport()66+ if m.help.renderer == nil {67+ t.Fatal("expected initial help renderer")68+ }69+70+ initialRenderer := m.help.renderer71+ initialThemeKey := m.help.rendererTheme72+73+ m = m.updateHelpDetailViewport()74+ if m.help.renderer != initialRenderer {75+ t.Fatal("expected help renderer to be reused when width and theme are unchanged")76+ }77+78+ if err := theme.Apply("Dracula"); err != nil {79+ t.Fatalf("apply Dracula theme: %v", err)80+ }81+82+ m = m.updateHelpDetailViewport()83+ if m.help.renderer == nil {84+ t.Fatal("expected help renderer after theme change")85+ }86+ if m.help.renderer == initialRenderer {87+ t.Fatal("expected help renderer to rebuild after theme change")88+ }89+ if m.help.rendererTheme == initialThemeKey {90+ t.Fatalf("renderer theme key = %q, want change from %q", m.help.rendererTheme, initialThemeKey)91+ }92+}
A app/mode_display.go +121 -0
@@ -0,0 +1,121 @@
1+package app2+3+import (4+ "strings"5+6+ "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type modeDisplayItem struct {11+ item list.Item12+ title string13+ description string14+ filterValue string15+}16+17+func (i modeDisplayItem) Title() string { return i.title }18+func (i modeDisplayItem) Description() string { return i.description }19+func (i modeDisplayItem) FilterValue() string { return i.filterValue }20+func (i modeDisplayItem) Original() list.Item { return i.item }21+22+func buildModeDisplayItems(cat game.Category) []list.Item {23+ titles := modeDisplayTitles(cat)24+ items := make([]list.Item, 0, len(cat.Modes))25+26+ for idx, item := range cat.Modes {27+ mode, ok := item.(game.Mode)28+ if !ok {29+ items = append(items, item)30+ continue31+ }32+33+ displayTitle := titles[idx]34+ filterValue := displayTitle + " " + mode.Description()35+ if displayTitle != mode.Title() {36+ filterValue += " " + mode.Title()37+ }38+39+ items = append(items, modeDisplayItem{40+ item: item,41+ title: displayTitle,42+ description: mode.Description(),43+ filterValue: strings.TrimSpace(filterValue),44+ })45+ }46+47+ return items48+}49+50+func modeDisplayTitles(cat game.Category) []string {51+ counts := make(map[string]int, len(cat.Modes))52+ titles := make([]string, 0, len(cat.Modes))53+54+ for _, item := range cat.Modes {55+ mode, ok := item.(game.Mode)56+ if !ok {57+ continue58+ }59+ base, _ := trimGridSizeSuffix(mode.Title())60+ counts[game.NormalizeName(base)]++61+ }62+63+ for _, item := range cat.Modes {64+ mode, ok := item.(game.Mode)65+ if !ok {66+ continue67+ }68+69+ title := mode.Title()70+ base, hadSize := trimGridSizeSuffix(title)71+ if hadSize && counts[game.NormalizeName(base)] == 1 {72+ title = base73+ }74+ titles = append(titles, title)75+ }76+77+ return titles78+}79+80+func unwrapModeDisplayItem(item list.Item) list.Item {81+ displayItem, ok := item.(interface{ Original() list.Item })82+ if !ok {83+ return item84+ }85+ return displayItem.Original()86+}87+88+func trimGridSizeSuffix(title string) (string, bool) {89+ fields := strings.Fields(title)90+ if len(fields) < 2 {91+ return title, false92+ }93+94+ if !isGridSizeToken(fields[len(fields)-1]) {95+ return title, false96+ }97+98+ base := strings.TrimSpace(strings.Join(fields[:len(fields)-1], " "))99+ if base == "" {100+ return title, false101+ }102+103+ return base, true104+}105+106+func isGridSizeToken(token string) bool {107+ width, height, found := strings.Cut(token, "x")108+ if !found || width == "" || height == "" {109+ return false110+ }111+ return digitsOnly(width) && digitsOnly(height)112+}113+114+func digitsOnly(s string) bool {115+ for _, r := range s {116+ if r < '0' || r > '9' {117+ return false118+ }119+ }120+ return true121+}
A app/mode_display_test.go +96 -0
@@ -0,0 +1,96 @@
1+package app2+3+import (4+ "testing"5+6+ "charm.land/bubbles/v2/list"7+ "github.com/FelineStateMachine/puzzletea/game"8+)9+10+type testMode struct {11+ game.BaseMode12+}13+14+func newTestMode(title string) testMode {15+ return testMode{BaseMode: game.NewBaseMode(title, title+" description")}16+}17+18+func TestModeDisplayTitlesStripUniqueGridSizes(t *testing.T) {19+ t.Parallel()20+21+ cat := game.Category{22+ Name: "Example",23+ Modes: []list.Item{24+ newTestMode("Mini 5x5"),25+ newTestMode("Hard 10x10"),26+ },27+ }28+29+ got := modeDisplayTitles(cat)30+ want := []string{"Mini", "Hard"}31+32+ if len(got) != len(want) {33+ t.Fatalf("len(modeDisplayTitles()) = %d, want %d", len(got), len(want))34+ }35+ for i := range want {36+ if got[i] != want[i] {37+ t.Fatalf("modeDisplayTitles()[%d] = %q, want %q", i, got[i], want[i])38+ }39+ }40+}41+42+func TestModeDisplayTitlesKeepDuplicateBaseNames(t *testing.T) {43+ t.Parallel()44+45+ cat := game.Category{46+ Name: "Example",47+ Modes: []list.Item{48+ newTestMode("Easy 5x5"),49+ newTestMode("Easy 10x10"),50+ newTestMode("Hard 10x10"),51+ },52+ }53+54+ got := modeDisplayTitles(cat)55+ want := []string{"Easy 5x5", "Easy 10x10", "Hard"}56+57+ if len(got) != len(want) {58+ t.Fatalf("len(modeDisplayTitles()) = %d, want %d", len(got), len(want))59+ }60+ for i := range want {61+ if got[i] != want[i] {62+ t.Fatalf("modeDisplayTitles()[%d] = %q, want %q", i, got[i], want[i])63+ }64+ }65+}66+67+func TestBuildModeDisplayItemsPreservesOriginalMode(t *testing.T) {68+ t.Parallel()69+70+ original := newTestMode("Hard 10x10")71+ cat := game.Category{72+ Name: "Example",73+ Modes: []list.Item{original},74+ }75+76+ items := buildModeDisplayItems(cat)77+ if len(items) != 1 {78+ t.Fatalf("len(buildModeDisplayItems()) = %d, want 1", len(items))79+ }80+81+ displayMode, ok := items[0].(game.Mode)82+ if !ok {83+ t.Fatal("buildModeDisplayItems()[0] does not implement game.Mode")84+ }85+ if got := displayMode.Title(); got != "Hard" {86+ t.Fatalf("display item Title() = %q, want %q", got, "Hard")87+ }88+89+ mode, ok := unwrapModeDisplayItem(items[0]).(game.Mode)90+ if !ok {91+ t.Fatal("unwrapModeDisplayItem() did not return a game.Mode")92+ }93+ if got := mode.Title(); got != "Hard 10x10" {94+ t.Fatalf("unwrapped mode Title() = %q, want %q", got, "Hard 10x10")95+ }96+}
M app/model.go +1 -0
M app/update.go +9 -4
@@ -144,18 +144,23 @@ func (m model) updateCategoryDetailViewport() model {
144144 145145 func (m model) updateHelpDetailViewport() model {146146 helpWidth, helpHeight := helpViewportSize(m.width, m.height)147- if m.help.renderer == nil || m.help.rendererWidth != helpWidth {147+ palette := theme.Current()148+ themeKey := helpMarkdownThemeKey(palette)149+ if m.help.renderer == nil || m.help.rendererWidth != helpWidth || m.help.rendererTheme != themeKey {148150 renderer, err := glamour.NewTermRenderer(149- glamour.WithAutoStyle(),151+ glamour.WithStyles(helpMarkdownStyle(palette)),150152 glamour.WithWordWrap(helpWidth),153+ glamour.WithChromaFormatter("terminal16m"),151154 )152155 if err != nil {153156 log.Printf("failed to create help renderer: %v", err)154157 m.help.renderer = nil155158 m.help.rendererWidth = 0159+ m.help.rendererTheme = ""156160 } else {157161 m.help.renderer = renderer158162 m.help.rendererWidth = helpWidth163+ m.help.rendererTheme = themeKey159164 }160165 }161166
@@ -614,14 +619,14 @@ func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {
614619 return m, nil615620 }616621 m.nav.selectedCategory = cat617- m.nav.modeSelectList = ui.InitList(cat.Modes, cat.Name+" - Select Mode")622+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(cat), cat.Name+" - Select Mode")618623 m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))619624 m.state = modeSelectView620625 return m, nil621626 }622627 623628 func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {624- item := m.nav.modeSelectList.SelectedItem()629+ item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())625630 mode, ok := item.(game.Mode)626631 if !ok {627632 return m, nil
M app/view.go +4 -7
M catalog/catalog.go +2 -0
M catalog/catalog_test.go +2 -0
M daily/daily_test.go +15 -0
M fillomino/Model.go +7 -1
M fillomino/fillomino_test.go +57 -0
@@ -474,6 +474,63 @@ func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {
474474 }475475 }476476 477+func TestCursorRegionInfoViewNumberedCell(t *testing.T) {478+ m := Model{479+ width: 3,480+ height: 3,481+ grid: grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}},482+ provided: newProvidedMask(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}),483+ conflicts: validateGridState(grid{{2, 2, 1}, {3, 0, 0}, {0, 0, 1}}).conflicts,484+ cursor: pointCursor(0, 0),485+ }486+487+ if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 2/2"; got != want {488+ t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)489+ }490+}491+492+func TestCursorRegionInfoViewEmptyRegion(t *testing.T) {493+ m := Model{494+ width: 3,495+ height: 3,496+ grid: grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}},497+ provided: newProvidedMask(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}),498+ conflicts: validateGridState(grid{{2, 2, 0}, {3, 0, 0}, {0, 0, 1}}).conflicts,499+ cursor: pointCursor(2, 1),500+ }501+502+ if got, want := ansi.Strip(cursorRegionInfoView(m)), "region size: 5/-"; got != want {503+ t.Fatalf("cursorRegionInfoView() = %q, want %q", got, want)504+ }505+}506+507+func TestViewKeepsStableHeightAcrossCursorRegionInfoChanges(t *testing.T) {508+ baseGrid := grid{509+ {2, 2, 1},510+ {3, 0, 0},511+ {0, 0, 1},512+ }513+ m := Model{514+ width: 3,515+ height: 3,516+ grid: baseGrid,517+ provided: newProvidedMask(baseGrid),518+ conflicts: validateGridState(baseGrid).conflicts,519+ modeTitle: "Test",520+ maxCellValue: 3,521+ }522+523+ numbered := m524+ numbered.cursor = pointCursor(0, 0)525+526+ empty := m527+ empty.cursor = pointCursor(2, 1)528+529+ if got, want := lipgloss.Height(empty.View()), lipgloss.Height(numbered.View()); got != want {530+ t.Fatalf("height(empty) = %d, want %d", got, want)531+ }532+}533+477534 func TestSolvedGapBackgroundUsesSolvedColor(t *testing.T) {478535 conflicts := validateGridState(grid{{2, 2}}).conflicts479536 m := Model{
M fillomino/style.go +41 -0
@@ -508,6 +508,47 @@ func completedRegionColor(comp component, colors []color.Color, base color.Color
508508 return theme.Blend(base, colors[index], 0.52)509509 }510510 511+func cursorRegionInfoStyle() lipgloss.Style {512+ return lipgloss.NewStyle().Foreground(theme.Current().Info)513+}514+515+func cursorRegionInfoView(m Model) string {516+ current, target := cursorRegionInfoData(m)517+ return cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(current) + "/" + target)518+}519+520+func cursorRegionInfoVariants(m Model) []string {521+ area := m.width * m.height522+ maxTarget := m.maxCellValue523+ if maxTarget < 1 {524+ maxTarget = 1525+ }526+527+ return []string{528+ cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/-"),529+ cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/" + strconv.Itoa(maxTarget)),530+ }531+}532+533+func cursorRegionInfoData(m Model) (current int, target string) {534+ if len(m.grid) == 0 || len(m.grid[0]) == 0 {535+ return 0, "-"536+ }537+538+ cursor := point{x: m.cursor.X, y: m.cursor.Y}539+ value := m.grid[cursor.y][cursor.x]540+ visited := make([][]bool, len(m.grid))541+ for y := range len(m.grid) {542+ visited[y] = make([]bool, len(m.grid[y]))543+ }544+545+ comp := buildComponent(m.grid, cursor, visited)546+ if value == 0 {547+ return len(comp.cells), "-"548+ }549+ return len(comp.cells), strconv.Itoa(value)550+}551+511552 func statusBarView(showFullHelp bool) string {512553 if showFullHelp {513554 return game.StatusBarStyle().Render("1-9: place bkspc: clear arrows/wasd: move esc: menu ctrl+r: reset ctrl+h: help")
M game/dynamic_grid.go +63 -9
@@ -11,7 +11,6 @@ import (
1111 const DynamicGridCellWidth = 31212 1313 const (14- dynamicGridCellStepX = DynamicGridCellWidth + 11514 dynamicGridCellStepY = 21615 )1716
@@ -42,6 +41,7 @@ type DynamicGridBridge struct {
4241 type DynamicGridSpec struct {4342 Width int4443 Height int44+ CellWidth int4545 Solved bool4646 Colors *GridBorderColors4747 Cell func(x, y int) string
@@ -50,6 +50,8 @@ type DynamicGridSpec struct {
5050 HasHorizontalEdge func(x, y int) bool5151 ZoneFill func(zone int) color.Color5252 BridgeFill func(bridge DynamicGridBridge) color.Color53+ BridgeForeground func(bridge DynamicGridBridge) color.Color54+ BridgeBold func(bridge DynamicGridBridge) bool5355 VerticalBridgeText func(x, y int) string5456 HorizontalBridgeText func(x, y int) string5557 }
@@ -64,6 +66,13 @@ func RenderDynamicGrid(spec DynamicGridSpec) string {
6466 return lipgloss.JoinVertical(lipgloss.Left, rows...)6567 }6668 69+func dynamicGridCellWidth(spec DynamicGridSpec) int {70+ if spec.CellWidth > 0 {71+ return spec.CellWidth72+ }73+ return DynamicGridCellWidth74+}75+6776 func DynamicGridOrigin(termW, termH int, fullView, title, grid string) (x, y int) {6877 viewWidth := lipgloss.Width(fullView)6978 viewHeight := lipgloss.Height(fullView)
@@ -188,7 +197,15 @@ func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge Dyna
188197 189198 if spec.VerticalBridgeText != nil {190199 if text := strings.TrimSpace(spec.VerticalBridgeText(x, y)); text != "" {191- return dynamicGridRenderBridgeText(text, 1, dynamicGridColors(spec), spec.Solved, dynamicGridBridgeBackground(spec, bridge))200+ return dynamicGridRenderBridgeText(201+ text,202+ 1,203+ dynamicGridColors(spec),204+ spec.Solved,205+ dynamicGridBridgeBackground(spec, bridge),206+ dynamicGridBridgeForeground(spec, bridge),207+ dynamicGridBridgeBold(spec, bridge),208+ )192209 }193210 }194211
@@ -201,6 +218,7 @@ func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge Dyna
201218 }202219 203220 func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {221+ cellWidth := dynamicGridCellWidth(spec)204222 if dynamicGridHasHorizontalEdge(spec, x, y) {205223 return strings.Repeat(206224 dynamicGridRenderBorderChar(
@@ -209,13 +227,21 @@ func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge Dy
209227 spec.Solved,210228 dynamicGridBridgeBackground(spec, bridge),211229 ),212- DynamicGridCellWidth,230+ cellWidth,213231 )214232 }215233 216234 if spec.HorizontalBridgeText != nil {217235 if text := strings.TrimSpace(spec.HorizontalBridgeText(x, y)); text != "" {218- return dynamicGridRenderBridgeText(text, DynamicGridCellWidth, dynamicGridColors(spec), spec.Solved, dynamicGridBridgeBackground(spec, bridge))236+ return dynamicGridRenderBridgeText(237+ text,238+ cellWidth,239+ dynamicGridColors(spec),240+ spec.Solved,241+ dynamicGridBridgeBackground(spec, bridge),242+ dynamicGridBridgeForeground(spec, bridge),243+ dynamicGridBridgeBold(spec, bridge),244+ )219245 }220246 }221247
@@ -226,19 +252,33 @@ func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge Dy
226252 spec.Solved,227253 dynamicGridBridgeBackground(spec, bridge),228254 ),229- DynamicGridCellWidth,255+ cellWidth,230256 )231257 }232258 233-func dynamicGridRenderBridgeText(text string, width int, colors GridBorderColors, solved bool, bg color.Color) string {259+func dynamicGridRenderBridgeText(260+ text string,261+ width int,262+ colors GridBorderColors,263+ solved bool,264+ bg color.Color,265+ fgOverride color.Color,266+ bold bool,267+) string {234268 fg, bg := borderColors(colors, solved, bg)269+ if fgOverride != nil {270+ fg = fgOverride271+ }235272 236- return lipgloss.NewStyle().273+ style := lipgloss.NewStyle().237274 Width(width).238275 AlignHorizontal(lipgloss.Center).239276 Foreground(fg).240- Background(bg).241- Render(text)277+ Background(bg)278+ if bold {279+ style = style.Bold(true)280+ }281+ return style.Render(text)242282 }243283 244284 func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {
@@ -313,6 +353,20 @@ func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge)
313353 return nil314354 }315355 356+func dynamicGridBridgeForeground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {357+ if spec.BridgeForeground != nil {358+ return spec.BridgeForeground(bridge)359+ }360+ return nil361+}362+363+func dynamicGridBridgeBold(spec DynamicGridSpec, bridge DynamicGridBridge) bool {364+ if spec.BridgeBold != nil {365+ return spec.BridgeBold(bridge)366+ }367+ return false368+}369+316370 func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {317371 if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {318372 return DynamicGridBridge{
M game/dynamic_grid_test.go +28 -0
M hashiwokakero/Model.go +24 -5
@@ -18,6 +18,11 @@ type Model struct {
1818 keys KeyMap1919 modeTitle string2020 showFullHelp bool21+ termWidth int22+ termHeight int23+ originX int24+ originY int25+ originValid bool2126 }2227 2328 func New(mode HashiMode, puzzle Puzzle) game.Gamer {
@@ -41,6 +46,19 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
4146 switch msg := msg.(type) {4247 case game.HelpToggleMsg:4348 m.showFullHelp = msg.Show49+ m.originValid = false50+ case tea.WindowSizeMsg:51+ m.termWidth = msg.Width52+ m.termHeight = msg.Height53+ m.originValid = false54+ case tea.MouseClickMsg:55+ if msg.Button == tea.MouseLeft {56+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {57+ if isl := m.puzzle.FindIslandAt(col, row); isl != nil {58+ m.cursorIsland = isl.ID59+ }60+ }61+ }4462 case tea.KeyPressMsg:4563 if m.selectedIsland != nil {4664 m = m.handleBridgeMode(msg)
@@ -122,6 +140,7 @@ func (m Model) cycleBridge(dx, dy int) Model {
122140 m.puzzle.SetBridge(*m.selectedIsland, adj.ID, newCount)123141 if m.puzzle.IsSolved() {124142 m.selectedIsland = nil143+ m.originValid = false125144 }126145 return m127146 }
@@ -138,13 +157,12 @@ func (m Model) View() string {
138157 139158 selected := m.selectedIsland != nil140159 status := statusBarView(selected, m.showFullHelp)141-142- return game.ComposeGameViewRows(143- title,144- grid,160+ rows := []game.SecondaryRow{145161 game.StaticRow(info),146162 game.StableRow(status, statusBarVariants()...),147- )163+ }164+165+ return game.ComposeGameViewRows(title, grid, rows...)148166 }149167 150168 func (m Model) SetTitle(t string) game.Gamer {
@@ -164,6 +182,7 @@ func (m Model) Reset() game.Gamer {
164182 m.cursorIsland = m.puzzle.Islands[0].ID165183 }166184 m.selectedIsland = nil185+ m.originValid = false167186 return m168187 }169188
M hashiwokakero/hashiwokakero_test.go +280 -0
@@ -2,10 +2,16 @@ package hashiwokakero
22 33 import (44 "encoding/json"5+ "image"6+ "image/color"7+ "strings"58 "testing"69 710 tea "charm.land/bubbletea/v2"811 "charm.land/lipgloss/v2"12+ "github.com/FelineStateMachine/puzzletea/game"13+ "github.com/FelineStateMachine/puzzletea/theme"14+ "github.com/charmbracelet/x/ansi"915 )1016 1117 // --- helpers ---
@@ -381,6 +387,280 @@ func TestViewHeightStableAcrossStatusModes(t *testing.T) {
381387 }382388 }383389 390+func TestScreenToGridUsesHashiCellWidth(t *testing.T) {391+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}392+393+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})394+ got := next.(Model)395+ x, y := got.cachedGridOrigin()396+397+ if col, row, ok := got.screenToGrid(x, y); !ok || col != 0 || row != 0 {398+ t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)399+ }400+ if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {401+ t.Fatal("screenToGrid(separator) = ok, want false")402+ }403+}404+405+func TestMouseClickMovesCursorToClickedIsland(t *testing.T) {406+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}407+408+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})409+ got := next.(Model)410+ ox, oy := got.cachedGridOrigin()411+412+ clickX := ox + (cellWidth+1)*3413+ clickY := oy414+ next, _ = got.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})415+ clicked := next.(Model)416+417+ if clicked.cursorIsland != 1 {418+ t.Fatalf("cursorIsland = %d, want %d", clicked.cursorIsland, 1)419+ }420+}421+422+func TestMouseClickIgnoresEmptyCells(t *testing.T) {423+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap, modeTitle: "Test"}424+425+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})426+ got := next.(Model)427+ ox, oy := got.cachedGridOrigin()428+429+ clickX := ox + (cellWidth + 1)430+ clickY := oy431+ next, _ = got.Update(tea.MouseClickMsg{X: clickX, Y: clickY, Button: tea.MouseLeft})432+ clicked := next.(Model)433+434+ if clicked.cursorIsland != 0 {435+ t.Fatalf("cursorIsland = %d, want unchanged 0", clicked.cursorIsland)436+ }437+}438+439+func TestGridViewUsesOnlyOuterBorders(t *testing.T) {440+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap}441+442+ lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")443+ if got, want := len(lines), 9; got != want {444+ t.Fatalf("line count = %d, want %d", got, want)445+ }446+447+ if !strings.HasPrefix(lines[0], "┌") || !strings.HasSuffix(lines[0], "┐") {448+ t.Fatalf("top border = %q, want outer corners", lines[0])449+ }450+ if !strings.HasPrefix(lines[len(lines)-1], "└") || !strings.HasSuffix(lines[len(lines)-1], "┘") {451+ t.Fatalf("bottom border = %q, want outer corners", lines[len(lines)-1])452+ }453+454+ for _, idx := range []int{1, 3, 5, 7} {455+ if got := strings.Count(lines[idx], "│"); got != 2 {456+ t.Fatalf("content row %d = %q, want only outer vertical borders", idx, lines[idx])457+ }458+ }459+ for _, idx := range []int{2, 4, 6} {460+ if strings.Contains(lines[idx], "─") {461+ t.Fatalf("separator row %d = %q, want no interior horizontal border glyphs", idx, lines[idx])462+ }463+ }464+}465+466+func TestGridViewRendersSingleAndDoubleHorizontalBridgesAcrossSeparators(t *testing.T) {467+ makePuzzle := func(count int) Puzzle {468+ p := Puzzle{469+ Width: 3,470+ Height: 1,471+ Islands: []Island{472+ {ID: 0, X: 0, Y: 0, Required: count},473+ {ID: 1, X: 2, Y: 0, Required: count},474+ },475+ }476+ p.SetBridge(0, 1, count)477+ return p478+ }479+480+ single := Model{puzzle: makePuzzle(1), cursorIsland: 0, keys: DefaultKeyMap}481+ singleRow := strings.Split(ansi.Strip(gridView(single, false)), "\n")[1]482+ if got := strings.Count(singleRow, "━"); got != 7 {483+ t.Fatalf("single bridge row = %q, want 7 heavy horizontal bridge glyphs including separators", singleRow)484+ }485+486+ double := Model{puzzle: makePuzzle(2), cursorIsland: 0, keys: DefaultKeyMap}487+ doubleRow := strings.Split(ansi.Strip(gridView(double, false)), "\n")[1]488+ if got := strings.Count(doubleRow, "═"); got != 7 {489+ t.Fatalf("double bridge row = %q, want 7 double-bridge glyphs including separators", doubleRow)490+ }491+}492+493+func TestGridViewRendersVerticalBridgeInSeparatorRows(t *testing.T) {494+ p := Puzzle{495+ Width: 1,496+ Height: 3,497+ Islands: []Island{498+ {ID: 0, X: 0, Y: 0, Required: 1},499+ {ID: 1, X: 0, Y: 2, Required: 1},500+ },501+ }502+ p.SetBridge(0, 1, 1)503+504+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}505+ lines := strings.Split(ansi.Strip(gridView(m, false)), "\n")506+507+ if !strings.Contains(lines[2], " ┃ ") {508+ t.Fatalf("upper separator row = %q, want vertical bridge glyph", lines[2])509+ }510+ if !strings.Contains(lines[3], " ┃ ") {511+ t.Fatalf("bridge cell row = %q, want vertical bridge glyph", lines[3])512+ }513+ if !strings.Contains(lines[4], " ┃ ") {514+ t.Fatalf("lower separator row = %q, want vertical bridge glyph", lines[4])515+ }516+}517+518+func TestResolveCellVisualCursorIsland(t *testing.T) {519+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 0, keys: DefaultKeyMap}520+521+ got := renderCellVisual(resolveCellVisual(m, 0, 0, false))522+ want := renderExpectedIslandCell(game.CursorLeft+"2"+game.CursorRight, game.CursorFG(), game.CursorBG(), true)523+ if got != want {524+ t.Fatalf("cursor island render = %q, want %q", got, want)525+ }526+}527+528+func TestResolveCellVisualSelectedIsland(t *testing.T) {529+ selected := 0530+ m := Model{puzzle: *fourCornerPuzzle(), cursorIsland: 1, selectedIsland: &selected, keys: DefaultKeyMap}531+532+ got := renderCellVisual(resolveCellVisual(m, 0, 0, false))533+ want := renderExpectedIslandCell(" 2 ", theme.TextOnBG(theme.Current().SelectionBG), theme.Current().SelectionBG, true)534+ if got != want {535+ t.Fatalf("selected island render = %q, want %q", got, want)536+ }537+}538+539+func TestResolveCellVisualSatisfiedAndOverfilledIslands(t *testing.T) {540+ satisfied := Puzzle{541+ Width: 3,542+ Height: 1,543+ Islands: []Island{544+ {ID: 0, X: 0, Y: 0, Required: 1},545+ {ID: 1, X: 2, Y: 0, Required: 1},546+ },547+ }548+ satisfied.SetBridge(0, 1, 1)549+ satisfiedModel := Model{puzzle: satisfied, cursorIsland: 1, keys: DefaultKeyMap}550+551+ gotSatisfied := renderCellVisual(resolveCellVisual(satisfiedModel, 0, 0, false))552+ wantSatisfied := renderExpectedIslandCell(" 1 ", theme.TextOnBG(theme.Current().SuccessBG), theme.Current().SuccessBG, true)553+ if gotSatisfied != wantSatisfied {554+ t.Fatalf("satisfied island render = %q, want %q", gotSatisfied, wantSatisfied)555+ }556+557+ overfilled := Puzzle{558+ Width: 5,559+ Height: 3,560+ Islands: []Island{561+ {ID: 0, X: 0, Y: 1, Required: 2},562+ {ID: 1, X: 2, Y: 1, Required: 2},563+ {ID: 2, X: 4, Y: 1, Required: 2},564+ {ID: 3, X: 2, Y: 0, Required: 1},565+ },566+ }567+ overfilled.SetBridge(0, 1, 1)568+ overfilled.SetBridge(1, 2, 1)569+ overfilled.SetBridge(1, 3, 1)570+ overfilledModel := Model{puzzle: overfilled, cursorIsland: 0, keys: DefaultKeyMap}571+572+ gotOverfilled := renderCellVisual(resolveCellVisual(overfilledModel, 2, 1, false))573+ wantOverfilled := renderExpectedIslandCell(" 2 ", theme.TextOnBG(theme.Current().ErrorBG), theme.Current().ErrorBG, true)574+ if gotOverfilled != wantOverfilled {575+ t.Fatalf("overfilled island render = %q, want %q", gotOverfilled, wantOverfilled)576+ }577+}578+579+func TestSolvedBridgeVisualStaysLegibleOverBlueBoard(t *testing.T) {580+ p := Puzzle{581+ Width: 3,582+ Height: 1,583+ Islands: []Island{584+ {ID: 0, X: 0, Y: 0, Required: 1},585+ {ID: 1, X: 2, Y: 0, Required: 1},586+ },587+ }588+ p.SetBridge(0, 1, 1)589+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}590+591+ empty := resolveCellVisual(m, 0, 0, true)592+ if !sameColor(empty.outerBG, boardBackground()) {593+ t.Fatal("expected solved island outer background to stay on the board color")594+ }595+596+ bridge := resolveCellVisual(m, 1, 0, true)597+ if !sameColor(bridge.bg, boardBackground()) {598+ t.Fatal("expected solved bridge cell to keep the blue board background")599+ }600+ if !sameColor(bridge.fg, bridgeColor(0)) {601+ t.Fatal("expected solved bridge cell to use the bridge palette color")602+ }603+}604+605+func TestBridgeForegroundUsesThemePalettePerBridge(t *testing.T) {606+ p := Puzzle{607+ Width: 5,608+ Height: 1,609+ Islands: []Island{610+ {ID: 0, X: 0, Y: 0, Required: 1},611+ {ID: 1, X: 2, Y: 0, Required: 2},612+ {ID: 2, X: 4, Y: 0, Required: 1},613+ },614+ }615+ p.SetBridge(0, 1, 1)616+ p.SetBridge(1, 2, 1)617+ m := Model{puzzle: p, cursorIsland: 0, keys: DefaultKeyMap}618+619+ leftBridge := game.DynamicGridBridge{620+ Kind: game.DynamicGridBridgeVertical,621+ X: 1,622+ Y: 0,623+ Count: 2,624+ Cells: [4]image.Point{{X: 0, Y: 0}, {X: 1, Y: 0}},625+ }626+ rightBridge := game.DynamicGridBridge{627+ Kind: game.DynamicGridBridgeVertical,628+ X: 3,629+ Y: 0,630+ Count: 2,631+ Cells: [4]image.Point{{X: 2, Y: 0}, {X: 3, Y: 0}},632+ }633+634+ if !sameColor(bridgeForeground(m, leftBridge), bridgeColor(0)) {635+ t.Fatal("expected first bridge to use the first palette color")636+ }637+ if !sameColor(bridgeForeground(m, rightBridge), bridgeColor(1)) {638+ t.Fatal("expected second bridge to use the second palette color")639+ }640+}641+642+func sameColor(left, right color.Color) bool {643+ if left == nil || right == nil {644+ return left == nil && right == nil645+ }646+ lr, lg, lb, la := left.RGBA()647+ rr, rg, rb, ra := right.RGBA()648+ return lr == rr && lg == rg && lb == rb && la == ra649+}650+651+func renderExpectedIslandCell(text string, fg, bg color.Color, bold bool) string {652+ side := lipgloss.NewStyle().Width((cellWidth - islandPillWidth) / 2).Background(boardBackground()).Render("")653+ center := lipgloss.NewStyle().654+ Width(islandPillWidth).655+ AlignHorizontal(lipgloss.Center).656+ Foreground(fg).657+ Background(bg)658+ if bold {659+ center = center.Bold(true)660+ }661+ return side + center.Render(text) + side662+}663+384664 // --- IsConnected (P0) ---385665 386666 func TestIsConnected(t *testing.T) {
A hashiwokakero/mouse.go +37 -0
M hashiwokakero/style.go +299 -192
@@ -10,84 +10,97 @@ import (
1010 "github.com/FelineStateMachine/puzzletea/theme"1111 )1212 13-const cellWidth = 313+const cellWidth = 514+const islandPillWidth = game.DynamicGridCellWidth15+16+type cellVisual struct {17+ text string18+ fg color.Color19+ bg color.Color20+ outerBG color.Color21+ bold bool22+ pill bool23+}24+25+func boardBackground() color.Color {26+ return theme.Current().BG27+}28+29+func baseIslandBackground() color.Color {30+ p := theme.Current()31+ return theme.Blend(p.BG, p.Success, 0.45)32+}33+34+func adjacentIslandBackground() color.Color {35+ return theme.Blend(baseIslandBackground(), theme.Current().Highlight, 0.12)36+}37+38+func solvedIslandBackground() color.Color {39+ return theme.Blend(baseIslandBackground(), theme.Current().Success, 0.18)40+}1441 15-func islandColors() []color.Color { return theme.Current().ThemeColors() }42+func baseCellStyle(fg, bg color.Color, bold bool) lipgloss.Style {43+ style := lipgloss.NewStyle().44+ Width(cellWidth).45+ AlignHorizontal(lipgloss.Center).46+ Foreground(fg).47+ Background(bg)48+ if bold {49+ style = style.Bold(true)50+ }51+ return style52+}1653 17-func islandDefaultStyle(islandID int) lipgloss.Style {18- colors := islandColors()19- bg := colors[islandID%len(colors)]54+func selectedIslandStyle() lipgloss.Style {55+ bg := theme.Current().SelectionBG2056 return lipgloss.NewStyle().57+ Width(islandPillWidth).58+ AlignHorizontal(lipgloss.Center).2159 Foreground(theme.TextOnBG(bg)).2260 Background(bg).2361 Bold(true)2462 }2563 26-func islandSatisfiedStyle() lipgloss.Style {64+func satisfiedIslandStyle() lipgloss.Style {2765 p := theme.Current()2866 return lipgloss.NewStyle().29- Foreground(p.SuccessBorder).67+ Width(islandPillWidth).68+ AlignHorizontal(lipgloss.Center).69+ Foreground(theme.TextOnBG(p.SuccessBG)).3070 Background(p.SuccessBG).3171 Bold(true)3272 }3373 34-func islandOverStyle() lipgloss.Style {74+func overfilledIslandStyle() lipgloss.Style {3575 p := theme.Current()3676 return lipgloss.NewStyle().37- Foreground(p.Error).77+ Width(islandPillWidth).78+ AlignHorizontal(lipgloss.Center).79+ Foreground(theme.TextOnBG(p.ErrorBG)).3880 Background(p.ErrorBG).3981 Bold(true)4082 }4183 42-// islandHighlightBGStyle returns the style for adjacent/neighbor islands.43-44-func islandAdjacentStyle(islandID int) lipgloss.Style {45- colors := islandColors()46- bg := colors[islandID%len(colors)]84+func adjacentIslandStyle() lipgloss.Style {85+ bg := adjacentIslandBackground()4786 return lipgloss.NewStyle().87+ Width(islandPillWidth).88+ AlignHorizontal(lipgloss.Center).4889 Foreground(theme.TextOnBG(bg)).4990 Background(bg).5091 Bold(true)5192 }5293 53-func bridgeStyle() lipgloss.Style {54- return lipgloss.NewStyle().55- Foreground(theme.Current().Linked)56-}57-58-func bridgeSolvedStyle() lipgloss.Style {59- return lipgloss.NewStyle().60- Foreground(theme.Current().SuccessBorder)61-}62-63-func emptyDotStyle() lipgloss.Style {64- return lipgloss.NewStyle().65- Foreground(theme.Current().TextDim)66-}67-68-func gridBorderStyle() lipgloss.Style {69- return lipgloss.NewStyle().70- Border(lipgloss.RoundedBorder()).71- Padding(1).72- BorderForeground(theme.Current().TextDim)73-}74-75-func gridBorderSolvedStyle() lipgloss.Style {76- return lipgloss.NewStyle().77- Border(lipgloss.RoundedBorder()).78- Padding(1).79- BorderForeground(theme.Current().SuccessBorder).80- BorderBackground(theme.Current().SuccessBG)94+func bridgeColors() []color.Color {95+ return theme.Current().ThemeColors()8196 }8297 83-// islandSelectedStyle returns the style for a selected island, using the84-// current theme's Highlight color for the background.85-func islandSelectedStyle() lipgloss.Style {86- p := theme.Current()87- return lipgloss.NewStyle().88- Foreground(p.Highlight).89- Background(p.SelectionBG).90- Bold(true)98+func bridgeColor(index int) color.Color {99+ colors := bridgeColors()100+ if index < 0 || len(colors) == 0 {101+ return theme.TextOnBG(boardBackground())102+ }103+ return colors[index%len(colors)]91104 }92105 93106 // isHighlightedNeighbor returns true if islandID is directly connectable
@@ -109,182 +122,275 @@ func isHighlightedNeighbor(m Model, islandID int) bool {
109122 return false110123 }111124 112-// emptyCellView renders an empty grid cell as a subtle dot.113-func emptyCellView(solved bool) string {114- p := theme.Current()115- s := emptyDotStyle()116- if solved {117- s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)125+func resolveCellVisual(m Model, x, y int, solved bool) cellVisual {126+ boardBG := boardBackground()127+ visual := cellVisual{128+ text: " ",129+ fg: theme.TextOnBG(boardBG),130+ bg: boardBG,131+ outerBG: boardBG,118132 }119- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("\u00b7")120-}121133 122-// gridView renders the puzzle using a display grid with gap cells between123-// logical cells so that bridges between directly adjacent islands are visible.124-// Display grid is (2*width-1) x (2*height-1): even coords are logical cells,125-// odd coords are gap cells showing bridge connectors.126-func gridView(m Model, solved bool) string {127- dispW := 2*m.puzzle.Width - 1128- dispH := 2*m.puzzle.Height - 1129-130- var rows []string131- for dy := range dispH {132- var cells []string133- for dx := range dispW {134- cells = append(cells, displayCellView(m, dx, dy, solved))134+ ci := m.puzzle.CellContent(x, y)135+ switch ci.Kind {136+ case cellIsland:137+ isl := m.puzzle.FindIslandByID(ci.IslandID)138+ if isl == nil {139+ return visual140+ }141+142+ label := fmt.Sprintf(" %d ", isl.Required)143+ isCursor := m.cursorIsland == ci.IslandID144+ current := m.puzzle.BridgeCount(ci.IslandID)145+ islandBG := baseIslandBackground()146+ visual.pill = true147+ visual.outerBG = boardBG148+149+ switch {150+ case solved && isCursor:151+ visual.fg = game.CursorFG()152+ visual.bg = theme.Current().SuccessBG153+ visual.bold = true154+ case solved:155+ islandBG = solvedIslandBackground()156+ visual.fg = theme.TextOnBG(islandBG)157+ visual.bg = islandBG158+ visual.bold = true159+ case m.selectedIsland != nil && *m.selectedIsland == ci.IslandID:160+ visual.fg = theme.TextOnBG(theme.Current().SelectionBG)161+ visual.bg = theme.Current().SelectionBG162+ visual.bold = true163+ case isCursor:164+ visual.fg = game.CursorFG()165+ visual.bg = game.CursorBG()166+ visual.bold = true167+ case current > isl.Required:168+ visual.fg = theme.TextOnBG(theme.Current().ErrorBG)169+ visual.bg = theme.Current().ErrorBG170+ visual.bold = true171+ case current == isl.Required:172+ visual.fg = theme.TextOnBG(theme.Current().SuccessBG)173+ visual.bg = theme.Current().SuccessBG174+ visual.bold = true175+ case isHighlightedNeighbor(m, ci.IslandID):176+ adjacentBG := adjacentIslandBackground()177+ visual.fg = theme.TextOnBG(adjacentBG)178+ visual.bg = adjacentBG179+ visual.bold = true180+ default:181+ visual.fg = theme.TextOnBG(islandBG)182+ visual.bg = islandBG183+ visual.bold = true135184 }136- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...))137- }138- grid := lipgloss.JoinVertical(lipgloss.Left, rows...)139185 140- if solved {141- return gridBorderSolvedStyle().Render(grid)186+ if isCursor && !solved {187+ label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight188+ }189+ visual.text = label190+191+ case cellBridgeH:192+ visual.text = horizontalBridgeGlyph(ci.BridgeCount)193+ visual.fg = bridgeColor(ci.BridgeIdx)194+ visual.bold = ci.BridgeCount == 2195+ case cellBridgeV:196+ visual.text = verticalBridgeGlyph(ci.BridgeCount)197+ visual.fg = bridgeColor(ci.BridgeIdx)198+ visual.bold = ci.BridgeCount == 2142199 }143- return gridBorderStyle().Render(grid)200+201+ return visual144202 }145203 146-// displayCellView renders a single cell in the display grid.147-func displayCellView(m Model, dx, dy int, solved bool) string {148- evenX := dx%2 == 0149- evenY := dy%2 == 0150-151- if evenX && evenY {152- // Logical cell position153- lx, ly := dx/2, dy/2154- ci := m.puzzle.CellContent(lx, ly)155- switch ci.Kind {156- case cellIsland:157- return islandView(m, ci.IslandID, solved)158- case cellBridgeH:159- return bridgeHView(ci.BridgeCount, solved)160- case cellBridgeV:161- return bridgeVView(ci.BridgeCount, solved)162- default:163- return emptyCellView(solved)204+func renderCellVisual(visual cellVisual) string {205+ if visual.pill {206+ sideWidth := max((cellWidth-islandPillWidth)/2, 0)207+ left := lipgloss.NewStyle().Width(sideWidth).Background(visual.outerBG).Render("")208+ right := lipgloss.NewStyle().Width(cellWidth - islandPillWidth - sideWidth).Background(visual.outerBG).Render("")209+ center := lipgloss.NewStyle().210+ Width(islandPillWidth).211+ AlignHorizontal(lipgloss.Center).212+ Foreground(visual.fg).213+ Background(visual.bg)214+ if visual.bold {215+ center = center.Bold(true)164216 }217+ return left + center.Render(visual.text) + right165218 }219+ return baseCellStyle(visual.fg, visual.bg, visual.bold).Render(visual.text)220+}166221 167- if !evenX && evenY {168- // Horizontal gap between (dx/2, dy/2) and (dx/2+1, dy/2)169- lx, ly := dx/2, dy/2170- return hGapView(m, lx, ly, solved)171- }222+func gridView(m Model, solved bool) string {223+ return game.RenderDynamicGrid(game.DynamicGridSpec{224+ Width: m.puzzle.Width,225+ Height: m.puzzle.Height,226+ CellWidth: cellWidth,227+ Solved: solved,228+ Cell: func(x, y int) string {229+ return renderCellVisual(resolveCellVisual(m, x, y, solved))230+ },231+ ZoneAt: func(_, _ int) int {232+ return 0233+ },234+ HasVerticalEdge: func(x, _ int) bool {235+ return x <= 0 || x >= m.puzzle.Width236+ },237+ HasHorizontalEdge: func(_, y int) bool {238+ return y <= 0 || y >= m.puzzle.Height239+ },240+ BridgeForeground: func(bridge game.DynamicGridBridge) color.Color {241+ return bridgeForeground(m, bridge)242+ },243+ BridgeBold: func(bridge game.DynamicGridBridge) bool {244+ return bridgeBold(m, bridge)245+ },246+ VerticalBridgeText: func(x, y int) string {247+ return horizontalSeparatorBridge(m, x, y)248+ },249+ HorizontalBridgeText: func(x, y int) string {250+ return verticalSeparatorBridge(m, x, y)251+ },252+ })253+}172254 173- if evenX && !evenY {174- // Vertical gap between (dx/2, dy/2) and (dx/2, dy/2+1)175- lx, ly := dx/2, dy/2176- return vGapView(m, lx, ly, solved)255+func horizontalBridgeGlyph(count int) string {256+ if count == 2 {257+ return "═════"177258 }178-179- // Odd x and odd y — intersection of gaps, always empty180- return lipgloss.NewStyle().Width(cellWidth).Render(" ")259+ return "━━━━━"181260 }182261 183-// hGapView renders the horizontal gap between logical cell (lx,ly) and (lx+1,ly).184-// Shows a bridge connector if both neighbors are islands connected by a bridge.185-func hGapView(m Model, lx, ly int, solved bool) string {186- left := m.puzzle.FindIslandAt(lx, ly)187- right := m.puzzle.FindIslandAt(lx+1, ly)188- if left != nil && right != nil {189- if b := m.puzzle.GetBridge(left.ID, right.ID); b != nil {190- return bridgeHView(b.Count, solved)191- }262+func verticalBridgeGlyph(count int) string {263+ if count == 2 {264+ return " ║ "192265 }193- // Also check if a bridge passes through this gap (non-adjacent islands)194- ci := m.puzzle.CellContent(lx, ly)195- if ci.Kind == cellBridgeH {196- return bridgeHView(ci.BridgeCount, solved)266+ return " ┃ "267+}268+269+func horizontalSeparatorBridge(m Model, x, y int) string {270+ count := horizontalBridgeCountAt(m.puzzle, x, y)271+ if count == 0 {272+ return ""197273 }198- ciR := m.puzzle.CellContent(lx+1, ly)199- if ciR.Kind == cellBridgeH {200- return bridgeHView(ciR.BridgeCount, solved)274+ if count == 2 {275+ return "═"201276 }202- return lipgloss.NewStyle().Width(cellWidth).Render(" ")277+ return "━"203278 }204279 205-// vGapView renders the vertical gap between logical cell (lx,ly) and (lx,ly+1).206-// Shows a bridge connector if both neighbors are islands connected by a bridge.207-func vGapView(m Model, lx, ly int, solved bool) string {208- top := m.puzzle.FindIslandAt(lx, ly)209- bottom := m.puzzle.FindIslandAt(lx, ly+1)210- if top != nil && bottom != nil {211- if b := m.puzzle.GetBridge(top.ID, bottom.ID); b != nil {212- return bridgeVView(b.Count, solved)213- }214- }215- // Also check if a bridge passes through this gap (non-adjacent islands)216- ci := m.puzzle.CellContent(lx, ly)217- if ci.Kind == cellBridgeV {218- return bridgeVView(ci.BridgeCount, solved)280+func verticalSeparatorBridge(m Model, x, y int) string {281+ count := verticalBridgeCountAt(m.puzzle, x, y)282+ if count == 0 {283+ return ""219284 }220- ciB := m.puzzle.CellContent(lx, ly+1)221- if ciB.Kind == cellBridgeV {222- return bridgeVView(ciB.BridgeCount, solved)285+ if count == 2 {286+ return " ║ "223287 }224- return lipgloss.NewStyle().Width(cellWidth).Render(" ")288+ return " ┃ "225289 }226290 227-func islandView(m Model, islandID int, solved bool) string {228- isl := m.puzzle.FindIslandByID(islandID)229- if isl == nil {230- return emptyDotStyle().Width(cellWidth).Render(" ")291+func horizontalBridgeCountAt(p Puzzle, x, y int) int {292+ if x <= 0 || x >= p.Width || y < 0 || y >= p.Height {293+ return 0231294 }232295 233- style := islandDefaultStyle(islandID)234- current := m.puzzle.BridgeCount(islandID)235- isCursor := m.cursorIsland == islandID236-237- if solved && isCursor {238- style = game.CursorSolvedStyle()239- } else if solved {240- colors := islandColors()241- bg := colors[islandID%len(colors)]242- style = lipgloss.NewStyle().243- Foreground(theme.TextOnBG(bg)).244- Background(bg).245- Bold(true)246- } else if m.selectedIsland != nil && *m.selectedIsland == islandID {247- style = islandSelectedStyle()248- } else if isCursor {249- style = game.CursorStyle()250- } else if current == isl.Required {251- style = islandSatisfiedStyle()252- } else if current > isl.Required {253- style = islandOverStyle()254- } else if isHighlightedNeighbor(m, islandID) {255- style = islandAdjacentStyle(islandID)296+ left := p.CellContent(x-1, y)297+ right := p.CellContent(x, y)298+299+ switch {300+ case left.Kind == cellBridgeH:301+ return left.BridgeCount302+ case right.Kind == cellBridgeH:303+ return right.BridgeCount304+ case left.Kind == cellIsland && right.Kind == cellIsland:305+ if b := p.GetBridge(left.IslandID, right.IslandID); b != nil {306+ return b.Count307+ }256308 }257309 258- label := fmt.Sprintf("%d", isl.Required)259- if isCursor && !solved {260- label = game.CursorLeft + fmt.Sprintf("%d", isl.Required) + game.CursorRight261- return style.Render(label)262- }263- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(label)310+ return 0264311 }265312 266-func bridgeHView(count int, solved bool) string {267- r := "\u2500\u2500\u2500"268- if count == 2 {269- r = "\u2550\u2550\u2550"313+func verticalBridgeCountAt(p Puzzle, x, y int) int {314+ if y <= 0 || y >= p.Height || x < 0 || x >= p.Width {315+ return 0270316 }271- s := bridgeStyle()272- if solved {273- s = bridgeSolvedStyle()317+318+ top := p.CellContent(x, y-1)319+ bottom := p.CellContent(x, y)320+321+ switch {322+ case top.Kind == cellBridgeV:323+ return top.BridgeCount324+ case bottom.Kind == cellBridgeV:325+ return bottom.BridgeCount326+ case top.Kind == cellIsland && bottom.Kind == cellIsland:327+ if b := p.GetBridge(top.IslandID, bottom.IslandID); b != nil {328+ return b.Count329+ }274330 }275- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)331+332+ return 0276333 }277334 278-func bridgeVView(count int, solved bool) string {279- r := "\u2502"280- if count == 2 {281- r = "\u2551"335+func bridgeForeground(m Model, bridge game.DynamicGridBridge) color.Color {336+ return bridgeColor(bridgeIndexForDynamicBridge(&m.puzzle, bridge))337+}338+339+func bridgeBold(m Model, bridge game.DynamicGridBridge) bool {340+ return bridgeCountForDynamicBridge(&m.puzzle, bridge) == 2341+}342+343+func bridgeIndexForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {344+ count, index := dynamicBridgeIdentity(p, bridge)345+ _ = count346+ return index347+}348+349+func bridgeCountForDynamicBridge(p *Puzzle, bridge game.DynamicGridBridge) int {350+ count, _ := dynamicBridgeIdentity(p, bridge)351+ return count352+}353+354+func dynamicBridgeIdentity(p *Puzzle, bridge game.DynamicGridBridge) (count int, index int) {355+ for i := 0; i < bridge.Count; i++ {356+ cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)357+ switch cell.Kind {358+ case cellBridgeH, cellBridgeV:359+ return cell.BridgeCount, cell.BridgeIdx360+ }282361 }283- s := bridgeStyle()284- if solved {285- s = bridgeSolvedStyle()362+363+ ids := make([]int, 0, 2)364+ for i := 0; i < bridge.Count; i++ {365+ cell := p.CellContent(bridge.Cells[i].X, bridge.Cells[i].Y)366+ if cell.Kind != cellIsland {367+ continue368+ }369+ duplicate := false370+ for _, id := range ids {371+ if id == cell.IslandID {372+ duplicate = true373+ break374+ }375+ }376+ if !duplicate {377+ ids = append(ids, cell.IslandID)378+ }286379 }287- return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)380+381+ if len(ids) == 2 {382+ if p.bridgeIndex == nil {383+ p.buildBridgeIndex()384+ }385+ if idx, ok := p.bridgeIndex[bridgeKey(ids[0], ids[1])]; ok {386+ if idx >= 0 && idx < len(p.Bridges) {387+ return p.Bridges[idx].Count, idx388+ }389+ return 0, idx390+ }391+ }392+393+ return 0, -1288394 }289395 290396 func statusBarView(selected, showFullHelp bool) string {
@@ -292,7 +398,7 @@ func statusBarView(selected, showFullHelp bool) string {
292398 if showFullHelp {293399 return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect esc: menu ctrl+r: reset ctrl+h: help")294400 }295- return game.StatusBarStyle().Render("arrows/wasd: build bridge enter/space: deselect")401+ return game.StatusBarStyle().Render("bridge mode arrows/wasd: build enter/space: deselect")296402 }297403 if showFullHelp {298404 return game.StatusBarStyle().Render("arrows/wasd: move enter/space: select island esc: menu ctrl+r: reset ctrl+h: help")
@@ -306,6 +412,7 @@ func statusBarVariants() []string {
306412 statusBarView(false, true),307413 statusBarView(true, false),308414 statusBarView(true, true),415+ game.StatusBarStyle().Render("arrows/wasd: build bridge\nenter/space: deselect"),309416 }310417 }311418
M pdfexport/jsonl_test.go +43 -0
@@ -211,6 +211,46 @@ func TestParseJSONLFileHydratesSudokuFromSave(t *testing.T) {
211211 }212212 }213213 214+func TestParseJSONLFileHydratesSudokuRGBFromSave(t *testing.T) {215+ path := filepath.Join(t.TempDir(), "sudoku-rgb.jsonl")216+ record := JSONLRecord{217+ Schema: ExportSchemaV1,218+ Pack: JSONLPackMeta{219+ Generated: "2026-02-22T10:00:00Z",220+ Version: "v-test",221+ Category: "Sudoku RGB",222+ ModeSelection: "Easy",223+ Count: 1,224+ },225+ Puzzle: JSONLPuzzle{226+ Index: 1,227+ Name: "ember-wave",228+ Game: "Sudoku RGB",229+ Mode: "Easy",230+ Save: json.RawMessage(`{"grid":"100000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000\n000000000","provided":[{"x":0,"y":0,"v":1},{"x":1,"y":0,"v":4},{"x":2,"y":0,"v":3}]}`),231+ },232+ }233+ writeSingleJSONLRecord(t, path, record)234+235+ doc, err := ParseJSONLFile(path)236+ if err != nil {237+ t.Fatal(err)238+ }239+ if got, want := len(doc.Puzzles), 1; got != want {240+ t.Fatalf("puzzles = %d, want %d", got, want)241+ }242+ payload, ok := doc.Puzzles[0].PrintPayload.(*SudokuData)243+ if !ok || payload == nil {244+ t.Fatal("expected sudoku rgb print payload from save hydration")245+ }246+ if got, want := payload.Givens[0][0], 1; got != want {247+ t.Fatalf("sudoku rgb givens[0][0] = %d, want %d", got, want)248+ }249+ if got := payload.Givens[0][1]; got != 0 {250+ t.Fatalf("sudoku rgb givens[0][1] = %d, want 0", got)251+ }252+}253+214254 func TestParseJSONLFileHydratesWordSearchFromSave(t *testing.T) {215255 path := filepath.Join(t.TempDir(), "wordsearch.jsonl")216256 record := JSONLRecord{
@@ -504,6 +544,9 @@ func ensureJSONLTestAdapters() {
504544 register("Sudoku", buildPayloadAdapter("Sudoku", func(save []byte) (any, error) {505545 return ParseSudokuPrintData(save)506546 }), "sudoku")547+ register("Sudoku RGB", buildPayloadAdapter("Sudoku RGB", func(save []byte) (any, error) {548+ return ParseSudokuRGBPrintData(save)549+ }), "sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto")507550 register("Fillomino", buildPayloadAdapter("Fillomino", func(save []byte) (any, error) {508551 return ParseFillominoPrintData(save)509552 }), "fillomino")
M pdfexport/printdata.go +24 -0
M pdfexport/printdata_test.go +21 -0
M resolve/resolve_test.go +18 -0
M sudoku/Model.go +24 -1
@@ -23,6 +23,11 @@ type Model struct {
2323 keys KeyMap2424 modeTitle string2525 showFullHelp bool26+ termWidth int27+ termHeight int28+ originX int29+ originY int30+ originValid bool2631 }2732 2833 func buildProvidedGrid(provided []cell) [gridSize][gridSize]bool {
@@ -54,6 +59,18 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
5459 switch msg := msg.(type) {5560 case game.HelpToggleMsg:5661 m.showFullHelp = msg.Show62+ m.originValid = false63+ case tea.WindowSizeMsg:64+ m.termWidth = msg.Width65+ m.termHeight = msg.Height66+ m.originValid = false67+ case tea.MouseClickMsg:68+ if msg.Button == tea.MouseLeft {69+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {70+ m.cursor.X = col71+ m.cursor.Y = row72+ }73+ }5774 case tea.KeyPressMsg:5875 switch {5976 case key.Matches(msg, m.keys.FillValue):
@@ -81,6 +98,7 @@ func (m Model) IsSolved() bool {
8198 func (m Model) Reset() game.Gamer {8299 m.grid = newGrid(m.provided)83100 m.conflicts = computeConflicts(m.grid)101+ m.originValid = false84102 return m85103 }86104
@@ -88,14 +106,19 @@ func (m *Model) updateCell(v int) {
88106 if m.providedGrid[m.cursor.Y][m.cursor.X] {89107 return90108 }109+110+ wasSolved := m.isSolved()91111 m.grid[m.cursor.Y][m.cursor.X].v = v92112 m.conflicts = computeConflicts(m.grid)113+ if m.isSolved() != wasSolved {114+ m.originValid = false115+ }93116 }94117 95118 func (m Model) View() string {96119 solved := isSolvedWith(m.grid, m.conflicts)97120 title := game.TitleBarView("Sudoku", m.modeTitle, solved)98- grid := renderGrid(m, solved, m.conflicts)121+ grid := renderGrid(m, solved)99122 if solved {100123 return game.ComposeGameView(title, grid)101124 }
M sudoku/README.md +1 -0
A sudoku/dynamic_board_test.go +410 -0
@@ -0,0 +1,410 @@
1+package sudoku2+3+import (4+ "image"5+ "image/color"6+ "strings"7+ "testing"8+9+ tea "charm.land/bubbletea/v2"10+ "charm.land/lipgloss/v2"11+ "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/theme"13+ "github.com/charmbracelet/x/ansi"14+)15+16+func TestRenderGridUsesBoxBoundaries(t *testing.T) {17+ m := Model{18+ grid: emptyGrid(),19+ conflicts: computeConflicts(emptyGrid()),20+ keys: DefaultKeyMap,21+ }22+23+ lines := strings.Split(ansi.Strip(renderGrid(m, false)), "\n")24+ if got, want := len(lines), 19; got != want {25+ t.Fatalf("line count = %d, want %d", got, want)26+ }27+28+ contentRow := []rune(lines[1])29+ if got := contentRow[4]; got != ' ' {30+ t.Fatalf("interior bridge rune = %q, want %q", got, ' ')31+ }32+ if got := contentRow[12]; got != '│' {33+ t.Fatalf("box boundary rune = %q, want %q", got, '│')34+ }35+36+ innerBoundary := []rune(lines[2])37+ if got := innerBoundary[1]; got != ' ' {38+ t.Fatalf("interior row separator = %q, want %q", got, ' ')39+ }40+41+ boxBoundary := []rune(lines[6])42+ if got := boxBoundary[1]; got != '─' {43+ t.Fatalf("box row separator = %q, want %q", got, '─')44+ }45+}46+47+func TestBridgeFillUsesAxisSpillOnly(t *testing.T) {48+ m := Model{cursor: game.Cursor{X: 1, Y: 1}}49+ surface := theme.Current().Surface50+51+ tests := []struct {52+ name string53+ bridge game.DynamicGridBridge54+ want color.Color55+ }{56+ {57+ name: "vertical bridge spills left right on cursor row",58+ bridge: game.DynamicGridBridge{59+ Kind: game.DynamicGridBridgeVertical,60+ X: 3,61+ Y: 1,62+ Cells: [4]image.Point{63+ image.Pt(2, 1),64+ image.Pt(3, 1),65+ },66+ Count: 2,67+ },68+ want: surface,69+ },70+ {71+ name: "horizontal bridge spills up down on cursor column",72+ bridge: game.DynamicGridBridge{73+ Kind: game.DynamicGridBridgeHorizontal,74+ X: 1,75+ Y: 3,76+ Cells: [4]image.Point{77+ image.Pt(1, 2),78+ image.Pt(1, 3),79+ },80+ Count: 2,81+ },82+ want: surface,83+ },84+ {85+ name: "off axis bridge stays empty",86+ bridge: game.DynamicGridBridge{87+ Kind: game.DynamicGridBridgeVertical,88+ X: 3,89+ Y: 4,90+ Cells: [4]image.Point{91+ image.Pt(2, 4),92+ image.Pt(3, 4),93+ },94+ Count: 2,95+ },96+ want: nil,97+ },98+ {99+ name: "junction stays empty",100+ bridge: game.DynamicGridBridge{101+ Kind: game.DynamicGridBridgeJunction,102+ X: 2,103+ Y: 1,104+ Cells: [4]image.Point{image.Pt(1, 0), image.Pt(1, 1), image.Pt(2, 0), image.Pt(2, 1)},105+ Count: 4,106+ Zone: 0,107+ Uniform: true,108+ },109+ want: nil,110+ },111+ }112+113+ for _, tt := range tests {114+ t.Run(tt.name, func(t *testing.T) {115+ if got := bridgeFill(m, false, tt.bridge); !sameColor(got, tt.want) {116+ t.Fatalf("bridgeFill() = %v, want %v", got, tt.want)117+ }118+ })119+ }120+}121+122+func TestZoneFillHighlightsOnlyActiveBox(t *testing.T) {123+ m := Model{cursor: game.Cursor{X: 1, Y: 1}}124+ surface := theme.Current().Surface125+126+ inBox := game.DynamicGridBridge{127+ Kind: game.DynamicGridBridgeVertical,128+ X: 2,129+ Y: 1,130+ Cells: [4]image.Point{image.Pt(1, 1), image.Pt(2, 1)},131+ Count: 2,132+ Zone: 0,133+ Uniform: true,134+ }135+ if got := activeBoxZoneFill(m.cursor, false, inBox.Zone); !sameColor(got, surface) {136+ t.Fatalf("in-box bridge background = %v, want %v", got, surface)137+ }138+139+ inBoxJunction := game.DynamicGridBridge{140+ Kind: game.DynamicGridBridgeJunction,141+ X: 2,142+ Y: 2,143+ Cells: [4]image.Point{image.Pt(1, 1), image.Pt(2, 1), image.Pt(1, 2), image.Pt(2, 2)},144+ Count: 4,145+ Zone: 0,146+ Uniform: true,147+ }148+ if got := activeBoxZoneFill(m.cursor, false, inBoxJunction.Zone); !sameColor(got, surface) {149+ t.Fatalf("in-box junction background = %v, want %v", got, surface)150+ }151+152+ outOfBox := game.DynamicGridBridge{153+ Kind: game.DynamicGridBridgeVertical,154+ X: 4,155+ Y: 1,156+ Cells: [4]image.Point{image.Pt(3, 1), image.Pt(4, 1)},157+ Count: 2,158+ Zone: 1,159+ Uniform: true,160+ }161+ if got := activeBoxZoneFill(m.cursor, false, outOfBox.Zone); got != nil {162+ t.Fatalf("out-of-box bridge background = %v, want nil", got)163+ }164+165+ if got := activeBoxZoneFill(m.cursor, true, inBox.Zone); got != nil {166+ t.Fatalf("solved in-box bridge background = %v, want nil", got)167+ }168+}169+170+func TestCellViewSameNumberOverridesProvidedCrosshair(t *testing.T) {171+ g := validCompleteGrid()172+ m := Model{173+ cursor: game.Cursor{X: 0, Y: 0},174+ grid: g,175+ keys: DefaultKeyMap,176+ }177+ m.providedGrid[4][4] = true178+179+ got := cellView(m, 4, 4, false)180+ want := sameNumberStyle(5).Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("5")181+ if got != want {182+ t.Fatalf("cellView() = %q, want %q", got, want)183+ }184+}185+186+func TestCellViewUsesSemanticDigitColors(t *testing.T) {187+ p := theme.Current()188+ g := emptyGrid()189+ g[0][0].v = 5190+ g[0][1].v = 7191+ m := Model{192+ cursor: game.Cursor{X: 8, Y: 8},193+ grid: g,194+ keys: DefaultKeyMap,195+ }196+ m.providedGrid[0][1] = true197+198+ gotUser := cellView(m, 0, 0, false)199+ wantUser := lipgloss.NewStyle().200+ Foreground(digitColor(5)).201+ Background(p.BG).202+ Width(cellWidth).203+ AlignHorizontal(lipgloss.Center).204+ Render("5")205+ if gotUser != wantUser {206+ t.Fatalf("user cellView() = %q, want %q", gotUser, wantUser)207+ }208+209+ gotProvided := cellView(m, 1, 0, false)210+ wantProvided := lipgloss.NewStyle().211+ Bold(true).212+ Foreground(digitColor(7)).213+ Background(p.BG).214+ Width(cellWidth).215+ AlignHorizontal(lipgloss.Center).216+ Render("7")217+ if gotProvided != wantProvided {218+ t.Fatalf("provided cellView() = %q, want %q", gotProvided, wantProvided)219+ }220+}221+222+func TestCellViewUsesDigitColorForFilledCursor(t *testing.T) {223+ g := emptyGrid()224+ g[1][1].v = 6225+ m := Model{226+ cursor: game.Cursor{X: 1, Y: 1},227+ grid: g,228+ keys: DefaultKeyMap,229+ }230+231+ got := cellView(m, 1, 1, false)232+ want := digitCursorStyle(6).233+ Width(cellWidth).234+ AlignHorizontal(lipgloss.Center).235+ Render(game.CursorLeft + "6" + game.CursorRight)236+ if got != want {237+ t.Fatalf("filled cursor cellView() = %q, want %q", got, want)238+ }239+}240+241+func TestCellViewKeepsDefaultCursorStyleForEmptyCell(t *testing.T) {242+ m := Model{243+ cursor: game.Cursor{X: 1, Y: 1},244+ grid: emptyGrid(),245+ keys: DefaultKeyMap,246+ }247+248+ got := cellView(m, 1, 1, false)249+ want := game.CursorStyle().250+ Width(cellWidth).251+ AlignHorizontal(lipgloss.Center).252+ Render(game.CursorLeft + "·" + game.CursorRight)253+ if got != want {254+ t.Fatalf("empty cursor cellView() = %q, want %q", got, want)255+ }256+}257+258+func TestScreenToGrid(t *testing.T) {259+ m := testDynamicBoardModel()260+ ox, oy := m.gridOrigin()261+262+ tests := []struct {263+ name string264+ screenX int265+ screenY int266+ wantCol int267+ wantRow int268+ wantOK bool269+ }{270+ {"origin cell", ox, oy, 0, 0, true},271+ {"next cell", ox + cellWidth + 1, oy, 1, 0, true},272+ {"next row", ox, oy + 2, 0, 1, true},273+ {"vertical separator", ox + cellWidth, oy, 0, 0, false},274+ {"horizontal separator", ox, oy + 1, 0, 0, false},275+ {"outside left", ox - 1, oy, 0, 0, false},276+ {"outside bottom", ox, oy + 18, 0, 0, false},277+ }278+279+ for _, tt := range tests {280+ t.Run(tt.name, func(t *testing.T) {281+ col, row, ok := m.screenToGrid(tt.screenX, tt.screenY)282+ if ok != tt.wantOK {283+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)284+ }285+ if ok && (col != tt.wantCol || row != tt.wantRow) {286+ t.Fatalf("screenToGrid(%d, %d) = (%d, %d), want (%d, %d)", tt.screenX, tt.screenY, col, row, tt.wantCol, tt.wantRow)287+ }288+ })289+ }290+}291+292+func TestMouseClickMovesCursor(t *testing.T) {293+ m := testDynamicBoardModel()294+295+ x, y := sudokuCellScreenCoords(&m, 2, 1)296+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})297+ got := next.(Model)298+299+ if got.cursor.X != 2 || got.cursor.Y != 1 {300+ t.Fatalf("cursor = (%d,%d), want (2,1)", got.cursor.X, got.cursor.Y)301+ }302+}303+304+func TestMouseClickOnSeparatorDoesNotMoveCursor(t *testing.T) {305+ m := testDynamicBoardModel()306+ m.cursor = game.Cursor{X: 1, Y: 1}307+308+ x, y := sudokuCellScreenCoords(&m, 1, 1)309+ next, _ := m.Update(tea.MouseClickMsg{X: x + cellWidth, Y: y, Button: tea.MouseLeft})310+ got := next.(Model)311+312+ if got.cursor.X != 1 || got.cursor.Y != 1 {313+ t.Fatalf("cursor = (%d,%d), want unchanged (1,1)", got.cursor.X, got.cursor.Y)314+ }315+}316+317+func TestMouseClickSameCellDoesNotMutateValue(t *testing.T) {318+ m := testDynamicBoardModel()319+ m.cursor = game.Cursor{X: 1, Y: 1}320+ m.grid[1][1].v = 4321+322+ x, y := sudokuCellScreenCoords(&m, 1, 1)323+ next, _ := m.Update(tea.MouseClickMsg{X: x, Y: y, Button: tea.MouseLeft})324+ got := next.(Model)325+326+ if got.grid[1][1].v != 4 {327+ t.Fatalf("cell value = %d, want unchanged 4", got.grid[1][1].v)328+ }329+}330+331+func TestOriginInvalidation(t *testing.T) {332+ t.Run("help toggle", func(t *testing.T) {333+ m := testDynamicBoardModel()334+ m.originValid = true335+336+ next, _ := m.Update(game.HelpToggleMsg{Show: true})337+ got := next.(Model)338+ if got.originValid {339+ t.Fatal("expected help toggle to invalidate origin cache")340+ }341+ })342+343+ t.Run("window size", func(t *testing.T) {344+ m := testDynamicBoardModel()345+ m.originValid = true346+347+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})348+ got := next.(Model)349+ if got.originValid {350+ t.Fatal("expected window resize to invalidate origin cache")351+ }352+ })353+354+ t.Run("reset", func(t *testing.T) {355+ m := testDynamicBoardModel()356+ m.originValid = true357+358+ got := m.Reset().(Model)359+ if got.originValid {360+ t.Fatal("expected reset to invalidate origin cache")361+ }362+ })363+364+ t.Run("solved transition", func(t *testing.T) {365+ g := validCompleteGrid()366+ g[0][0].v = 0367+368+ m := Model{369+ grid: g,370+ conflicts: computeConflicts(g),371+ keys: DefaultKeyMap,372+ originValid: true,373+ }374+375+ m.updateCell(5)376+ if m.originValid {377+ t.Fatal("expected solving move to invalidate origin cache")378+ }379+ if !m.isSolved() {380+ t.Fatal("expected board to be solved after filling final cell")381+ }382+ })383+}384+385+func testDynamicBoardModel() Model {386+ g := emptyGrid()387+ return Model{388+ grid: g,389+ conflicts: computeConflicts(g),390+ keys: DefaultKeyMap,391+ modeTitle: "Test",392+ termWidth: 120,393+ termHeight: 40,394+ }395+}396+397+func sudokuCellScreenCoords(m *Model, col, row int) (int, int) {398+ ox, oy := m.gridOrigin()399+ return ox + col*(cellWidth+1), oy + row*2400+}401+402+func sameColor(a, b color.Color) bool {403+ if a == nil || b == nil {404+ return a == b405+ }406+407+ ar, ag, ab, aa := a.RGBA()408+ br, bg, bb, ba := b.RGBA()409+ return ar == br && ag == bg && ab == bb && aa == ba410+}
M sudoku/help.md +1 -0
A sudoku/mouse.go +33 -0
M sudoku/style.go +137 -111
@@ -1,15 +1,15 @@
11 package sudoku22 33 import (4- "fmt"5- "strings"4+ "image/color"5+ "strconv"66 77 "charm.land/lipgloss/v2"88 "github.com/FelineStateMachine/puzzletea/game"99 "github.com/FelineStateMachine/puzzletea/theme"1010 )1111 12-var sudokuCellWidth = 212+const cellWidth = game.DynamicGridCellWidth1313 1414 func emptyCellStyle() lipgloss.Style {1515 p := theme.Current()
@@ -18,18 +18,18 @@ func emptyCellStyle() lipgloss.Style {
1818 Background(p.BG)1919 }2020 21-func providedCellStyle() lipgloss.Style {21+func providedCellStyle(v int) lipgloss.Style {2222 p := theme.Current()2323 return lipgloss.NewStyle().2424 Bold(true).25- Foreground(p.Given).25+ Foreground(digitColor(v)).2626 Background(p.BG)2727 }2828 29-func userCellStyle() lipgloss.Style {29+func userCellStyle(v int) lipgloss.Style {3030 p := theme.Current()3131 return lipgloss.NewStyle().32- Foreground(p.AccentSoft).32+ Foreground(digitColor(v)).3333 Background(p.BG)3434 }3535
@@ -40,150 +40,176 @@ func conflictCellStyle() lipgloss.Style {
4040 Background(p.ErrorBG)4141 }4242 43-func sameNumberStyle() lipgloss.Style {44- p := theme.Current()43+func sameNumberStyle(v int) lipgloss.Style {4544 return lipgloss.NewStyle().4645 Bold(true).47- Foreground(p.Accent)46+ Foreground(digitColor(v))4847 }4948 50-func gridBorderStyle() lipgloss.Style {51- p := theme.Current()49+func digitCursorStyle(value int) lipgloss.Style {50+ bg := digitColor(value)5251 return lipgloss.NewStyle().53- Border(lipgloss.RoundedBorder()).54- BorderForeground(p.Border).55- BorderBackground(p.BG)52+ Bold(true).53+ Foreground(theme.TextOnBG(bg)).54+ Background(bg)5655 }5756 58-func gridBorderSolvedStyle() lipgloss.Style {59- p := theme.Current()60- return lipgloss.NewStyle().61- Border(lipgloss.RoundedBorder()).62- BorderForeground(p.SuccessBorder).63- BorderBackground(p.SuccessBG)57+func renderGrid(m Model, solved bool) string {58+ return game.RenderDynamicGrid(game.DynamicGridSpec{59+ Width: gridSize,60+ Height: gridSize,61+ Solved: solved,62+ Cell: func(x, y int) string {63+ return cellView(m, x, y, solved)64+ },65+ ZoneAt: func(x, y int) int {66+ return sudokuBoxIndex(x, y)67+ },68+ ZoneFill: func(zone int) color.Color {69+ return activeBoxZoneFill(m.cursor, solved, zone)70+ },71+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {72+ return bridgeFill(m, solved, bridge)73+ },74+ })6475 }6576 66-func renderGrid(m Model, solved bool, conflicts [gridSize][gridSize]bool) string {67- p := theme.Current()68-69- var rows []string77+func cellView(m Model, x, y int, solved bool) string {78+ c := m.grid[y][x]79+ style := cellStyle(m, c, x, y, m.conflicts[y][x], solved)80+ text := cellContent(c)7081 71- for y := range gridSize {72- var cells []string73- for x := range gridSize {74- c := m.grid[y][x]75- style := getCellStyle(m, c, x, y, conflicts[y][x], solved)76- content := cellContent(c)77- rendered := style.Width(sudokuCellWidth).Align(lipgloss.Center).Render(content)78- cells = append(cells, rendered)79-80- if x == 2 || x == 5 {81- bg := p.BG82- if m.cursor.Y == y {83- bg = p.Surface84- }85- sep := lipgloss.NewStyle().Foreground(p.Border).Background(bg).Render("\u2502")86- cells = append(cells, sep)87- }88- }89- row := lipgloss.JoinHorizontal(lipgloss.Top, cells...)90- rows = append(rows, row)91-92- if y == 2 || y == 5 {93- sepLine := strings.Repeat("\u2500", sudokuCellWidth)94- var renderedParts []string95- for x := range gridSize {96- bg := p.BG97- fg := p.Border98- if solved {99- bg = p.SuccessBG100- fg = p.SuccessBorder101- } else if m.cursor.X == x {102- bg = p.Surface103- }104- renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(fg).Background(bg).Render(sepLine))105- if x == 2 || x == 5 {106- crossBG := p.BG107- crossFG := p.Border108- if solved {109- crossBG = p.SuccessBG110- crossFG = p.SuccessBorder111- }112- renderedParts = append(renderedParts, lipgloss.NewStyle().Foreground(crossFG).Background(crossBG).Render("\u253c"))113- }114- }115- rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, renderedParts...))82+ if x == m.cursor.X && y == m.cursor.Y {83+ if c.v == 0 {84+ text = game.CursorLeft + "·" + game.CursorRight85+ } else {86+ text = game.CursorLeft + strconv.Itoa(c.v) + game.CursorRight11687 }11788 }11889 119- grid := lipgloss.JoinVertical(lipgloss.Left, rows...)120-121- if solved {122- return gridBorderSolvedStyle().Render(grid)123- }124- return gridBorderStyle().Render(grid)90+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)12591 }12692 127-func getCellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {93+func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {12894 p := theme.Current()12995 isCursor := m.cursor.X == x && m.cursor.Y == y13096 cursorVal := m.grid[m.cursor.Y][m.cursor.X].v13197 132- // Priority: cursor+solved > cursor > conflict > same number > provided > user > empty133- if isCursor && solved {98+ switch {99+ case isCursor && solved:100+ if c.v != 0 {101+ return digitCursorStyle(c.v)102+ }134103 return game.CursorSolvedStyle()104+ case isCursor:105+ if c.v != 0 {106+ return digitCursorStyle(c.v)107+ }108+ return game.CursorStyle()109+ case solved:110+ return lipgloss.NewStyle().111+ Foreground(p.SolvedFG).112+ Background(p.SuccessBG)113+ case conflict:114+ return conflictCellStyle()115+ case cursorVal != 0 && c.v == cursorVal:116+ return sameNumberStyle(c.v)135117 }136118 137- if isCursor {138- return game.CursorStyle()119+ isProvided := m.providedGrid[y][x]120+ if isProvided {121+ style := providedCellStyle(c.v)122+ if inCursorContext(m.cursor, x, y) {123+ style = style.Background(p.Surface)124+ }125+ return style139126 }140127 141- if solved {142- return lipgloss.NewStyle().Foreground(p.SolvedFG).Background(p.SuccessBG)128+ if c.v != 0 {129+ style := userCellStyle(c.v)130+ if inCursorContext(m.cursor, x, y) {131+ style = style.Background(p.Surface)132+ }133+ return style143134 }144135 145- if conflict {146- return conflictCellStyle()136+ style := emptyCellStyle()137+ if inCursorContext(m.cursor, x, y) {138+ style = style.Background(p.Surface)147139 }140+ return style141+}148142 149- if cursorVal != 0 && c.v == cursorVal {150- return sameNumberStyle()143+func bridgeFill(m Model, solved bool, bridge game.DynamicGridBridge) color.Color {144+ if solved {145+ return nil151146 }152147 153- isProvided := m.providedGrid[y][x]154- inCursorRow := m.cursor.Y == y155- inCursorCol := m.cursor.X == x156- inCursorBox := (x/3 == m.cursor.X/3) && (y/3 == m.cursor.Y/3)157- inCrosshair := inCursorRow || inCursorCol || inCursorBox148+ if bridgeOnCrosshairAxis(m.cursor, bridge) {149+ return theme.Current().Surface150+ }151+ return nil152+}158153 159- if isProvided {160- s := providedCellStyle()161- if inCrosshair {162- s = s.Background(p.Surface)163- }164- return s154+func activeBoxZoneFill(cursor game.Cursor, solved bool, zone int) color.Color {155+ if solved || zone != sudokuBoxIndex(cursor.X, cursor.Y) {156+ return nil165157 }158+ return theme.Current().Surface159+}166160 167- if c.v != 0 {168- s := userCellStyle()169- if inCrosshair {170- s = s.Background(p.Surface)161+func bridgeOnCrosshairAxis(cursor game.Cursor, bridge game.DynamicGridBridge) bool {162+ switch bridge.Kind {163+ case game.DynamicGridBridgeVertical:164+ if bridge.Count == 0 {165+ return bridge.Y == cursor.Y166+ }167+ for i := 0; i < bridge.Count; i++ {168+ if bridge.Cells[i].Y == cursor.Y {169+ return true170+ }171+ }172+ case game.DynamicGridBridgeHorizontal:173+ if bridge.Count == 0 {174+ return bridge.X == cursor.X175+ }176+ for i := 0; i < bridge.Count; i++ {177+ if bridge.Cells[i].X == cursor.X {178+ return true179+ }171180 }172- return s173181 }182+ return false183+}174184 175- s := emptyCellStyle()176- if inCrosshair {177- s = s.Background(p.Surface)185+func inCursorContext(cursor game.Cursor, x, y int) bool {186+ return cursor.X == x ||187+ cursor.Y == y ||188+ sudokuBoxIndex(cursor.X, cursor.Y) == sudokuBoxIndex(x, y)189+}190+191+func sudokuBoxIndex(x, y int) int {192+ return (y / 3 * 3) + (x / 3)193+}194+195+func digitColor(value int) color.Color {196+ if value <= 0 {197+ return theme.Current().TextDim178198 }179- return s199+200+ colors := theme.Current().ThemeColors()201+ if len(colors) == 0 {202+ return theme.Current().FG203+ }204+205+ return colors[(value-1)%len(colors)]180206 }181207 182208 func cellContent(c cell) string {183209 if c.v == 0 {184- return "\u00b7"210+ return "·"185211 }186- return fmt.Sprintf("%d", c.v)212+ return strconv.Itoa(c.v)187213 }188214 189215 // computeConflicts returns a grid of booleans indicating which cells have conflicts.
@@ -253,7 +279,7 @@ func computeConflicts(g grid) [gridSize][gridSize]bool {
253279 254280 func statusBarView(showFullHelp bool) string {255281 if showFullHelp {256- return game.StatusBarStyle().Render("arrows/wasd: move 1-9: fill bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")282+ return game.StatusBarStyle().Render("mouse: click focus arrows/wasd: move 1-9: fill bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")257283 }258- return game.StatusBarStyle().Render("1-9: fill bkspc: clear")284+ return game.StatusBarStyle().Render("mouse: click focus 1-9: fill bkspc: clear")259285 }
A sudokurgb/Export.go +81 -0
@@ -0,0 +1,81 @@
1+package sudokurgb2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type exportCell struct {10+ X int `json:"x"`11+ Y int `json:"y"`12+ V int `json:"v"`13+}14+15+type Save struct {16+ Grid string `json:"grid"`17+ Provided []exportCell `json:"provided"`18+ ModeTitle string `json:"mode_title,omitempty"`19+}20+21+func gridToString(g grid) string {22+ var b strings.Builder23+ for y := range gridSize {24+ for x := range gridSize {25+ b.WriteByte(byte('0' + g[y][x].v))26+ }27+ if y < gridSize-1 {28+ b.WriteByte('\n')29+ }30+ }31+ return b.String()32+}33+34+func ImportModel(data []byte) (*Model, error) {35+ var save Save36+ if err := json.Unmarshal(data, &save); err != nil {37+ return nil, err38+ }39+40+ provided := make([]cell, len(save.Provided))41+ for i, ec := range save.Provided {42+ provided[i] = cell{x: ec.X, y: ec.Y, v: ec.V}43+ }44+45+ g := newGrid(provided)46+ rows := strings.Split(save.Grid, "\n")47+ for y := 0; y < gridSize && y < len(rows); y++ {48+ for x := 0; x < gridSize && x < len(rows[y]); x++ {49+ value := int(rows[y][x] - '0')50+ if value >= 1 && value <= valueCount {51+ g[y][x].v = value52+ }53+ }54+ }55+56+ return &Model{57+ grid: g,58+ provided: provided,59+ providedGrid: buildProvidedGrid(provided),60+ conflicts: computeConflicts(g),61+ modeTitle: save.ModeTitle,62+ keys: DefaultKeyMap,63+ }, nil64+}65+66+func (m Model) GetSave() ([]byte, error) {67+ provided := make([]exportCell, len(m.provided))68+ for i, c := range m.provided {69+ provided[i] = exportCell{X: c.x, Y: c.y, V: c.v}70+ }71+ save := Save{72+ Grid: gridToString(m.grid),73+ Provided: provided,74+ ModeTitle: m.modeTitle,75+ }76+ jsonData, err := json.Marshal(save)77+ if err != nil {78+ return nil, fmt.Errorf("unable to marshal save data: %w", err)79+ }80+ return jsonData, nil81+}
A sudokurgb/Gamemode.go +62 -0
@@ -0,0 +1,62 @@
1+package sudokurgb2+3+import (4+ _ "embed"5+ "math/rand/v2"6+7+ "charm.land/bubbles/v2/list"8+ "github.com/FelineStateMachine/puzzletea/game"9+)10+11+//go:embed help.md12+var HelpContent string13+14+type SudokuRGBMode struct {15+ game.BaseMode16+ ProvidedCount int17+}18+19+var (20+ _ game.Mode = SudokuRGBMode{}21+ _ game.Spawner = SudokuRGBMode{}22+ _ game.SeededSpawner = SudokuRGBMode{}23+)24+25+func NewMode(title, description string, providedCount int) SudokuRGBMode {26+ return SudokuRGBMode{27+ BaseMode: game.NewBaseMode(title, description),28+ ProvidedCount: providedCount,29+ }30+}31+32+func (s SudokuRGBMode) Spawn() (game.Gamer, error) {33+ return New(s, GenerateProvidedCells(s))34+}35+36+func (s SudokuRGBMode) SpawnSeeded(rng *rand.Rand) (game.Gamer, error) {37+ return New(s, GenerateProvidedCellsSeeded(s, rng))38+}39+40+var Modes = []list.Item{41+ NewMode("Beginner", "60 clues. Gentle intro to RGB quota logic.", 60),42+ NewMode("Easy", "54 clues. Early rows and boxes resolve quickly.", 54),43+ NewMode("Medium", "48 clues. Mixed row, column, and box pressure.", 48),44+ NewMode("Hard", "42 clues. More ambiguous houses and cross-checking.", 42),45+ NewMode("Expert", "36 clues. Sparse givens require deeper scanning.", 36),46+ NewMode("Diabolical", "30 clues. Tightest clue budget in the launch set.", 30),47+}48+49+var DailyModes = []list.Item{50+ Modes[1], // Easy51+ Modes[2], // Medium52+}53+54+var Definition = game.Definition{55+ Name: "Sudoku RGB",56+ Description: "Fill the board with RGB symbols so each row, column, and 3x3 box contains {1,1,1,2,2,2,3,3,3}. [1,2,3] maps to [▲,■,●]. Inspired by Sudoku Ripeto.",57+ Aliases: []string{"rgb sudoku", "ripeto", "sudoku ripeto"},58+ Modes: Modes,59+ DailyModes: DailyModes,60+ Help: HelpContent,61+ Import: func(data []byte) (game.Gamer, error) { return ImportModel(data) },62+}
A sudokurgb/Model.go +175 -0
@@ -0,0 +1,175 @@
1+package sudokurgb2+3+import (4+ "fmt"5+ "strconv"6+7+ "charm.land/bubbles/v2/key"8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+)11+12+const (13+ gridSize = 914+ valueCount = 315+ houseQuota = 316+)17+18+var _ game.Gamer = Model{}19+20+type Model struct {21+ cursor game.Cursor22+ grid grid23+ provided []cell24+ providedGrid [gridSize][gridSize]bool25+ conflicts [gridSize][gridSize]bool26+ keys KeyMap27+ modeTitle string28+ showFullHelp bool29+ termWidth int30+ termHeight int31+ originX int32+ originY int33+ originValid bool34+}35+36+func buildProvidedGrid(provided []cell) [gridSize][gridSize]bool {37+ var pg [gridSize][gridSize]bool38+ for _, c := range provided {39+ pg[c.y][c.x] = true40+ }41+ return pg42+}43+44+func New(mode SudokuRGBMode, provided []cell) (game.Gamer, error) {45+ g := newGrid(provided)46+ m := Model{47+ grid: g,48+ provided: provided,49+ providedGrid: buildProvidedGrid(provided),50+ conflicts: computeConflicts(g),51+ keys: DefaultKeyMap,52+ modeTitle: mode.Title(),53+ }54+ return m, nil55+}56+57+func (m Model) Init() tea.Cmd {58+ return nil59+}60+61+func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {62+ switch msg := msg.(type) {63+ case game.HelpToggleMsg:64+ m.showFullHelp = msg.Show65+ m.originValid = false66+ case tea.WindowSizeMsg:67+ m.termWidth = msg.Width68+ m.termHeight = msg.Height69+ m.originValid = false70+ case tea.MouseClickMsg:71+ if msg.Button == tea.MouseLeft {72+ if col, row, ok := m.screenToGrid(msg.X, msg.Y); ok {73+ m.cursor.X = col74+ m.cursor.Y = row75+ }76+ }77+ case tea.KeyPressMsg:78+ switch {79+ case key.Matches(msg, m.keys.FillValue):80+ value, _ := strconv.Atoi(msg.String())81+ m.updateCell(value)82+ case key.Matches(msg, m.keys.ClearCell):83+ m.updateCell(0)84+ default:85+ m.cursor.Move(m.keys.CursorKeyMap, msg, gridSize-1, gridSize-1)86+ }87+ }88+89+ m.updateKeyBindings()90+ return m, nil91+}92+93+func (m Model) SetTitle(t string) game.Gamer {94+ m.modeTitle = t95+ return m96+}97+98+func (m Model) IsSolved() bool {99+ return m.isSolved()100+}101+102+func (m Model) Reset() game.Gamer {103+ m.grid = newGrid(m.provided)104+ m.conflicts = computeConflicts(m.grid)105+ m.originValid = false106+ return m107+}108+109+func (m *Model) updateCell(value int) {110+ if m.providedGrid[m.cursor.Y][m.cursor.X] {111+ return112+ }113+114+ wasSolved := m.isSolved()115+ m.grid[m.cursor.Y][m.cursor.X].v = value116+ m.conflicts = computeConflicts(m.grid)117+ if m.isSolved() != wasSolved {118+ m.originValid = false119+ }120+}121+122+func (m Model) View() string {123+ solved := isSolvedWith(m.grid, m.conflicts)124+ title := game.TitleBarView("Sudoku RGB", m.modeTitle, solved)125+ grid := renderGrid(m, solved)126+ if solved {127+ return game.ComposeGameView(title, grid)128+ }129+130+ status := statusBarView(m.showFullHelp)131+ return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))132+}133+134+func (m Model) GetDebugInfo() string {135+ cursorCell := m.grid[m.cursor.Y][m.cursor.X]136+ isProvided := m.providedGrid[m.cursor.Y][m.cursor.X]137+ conflict := m.conflicts[m.cursor.Y][m.cursor.X]138+ solved := m.isSolved()139+140+ filledCount := 0141+ conflictCount := 0142+ for y := range gridSize {143+ for x := range gridSize {144+ if m.grid[y][x].v != 0 {145+ filledCount++146+ }147+ if m.conflicts[y][x] {148+ conflictCount++149+ }150+ }151+ }152+153+ status := "In Progress"154+ if solved {155+ status = "Solved"156+ }157+158+ return game.DebugHeader("Sudoku RGB", [][2]string{159+ {"Status", status},160+ {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},161+ {"Cell Value", cellDebugValue(cursorCell.v)},162+ {"Is Provided", fmt.Sprintf("%v", isProvided)},163+ {"Has Conflict", fmt.Sprintf("%v", conflict)},164+ {"Cells Filled", fmt.Sprintf("%d / 81", filledCount)},165+ {"Conflict Count", fmt.Sprintf("%d", conflictCount)},166+ {"Provided Count", fmt.Sprintf("%d", len(m.provided))},167+ })168+}169+170+func cellDebugValue(value int) string {171+ if value == 0 {172+ return "empty"173+ }174+ return fmt.Sprintf("%d (%s)", value, cellContentValue(value))175+}
A sudokurgb/PrintAdapter.go +114 -0
@@ -0,0 +1,114 @@
1+package sudokurgb2+3+import (4+ "strconv"5+6+ "codeberg.org/go-pdf/fpdf"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/pdfexport"9+)10+11+type printAdapter struct{}12+13+func (printAdapter) CanonicalGameType() string { return "Sudoku RGB" }14+func (printAdapter) Aliases() []string {15+ return []string{"sudoku rgb", "rgb sudoku", "ripeto", "sudoku ripeto"}16+}17+18+func (printAdapter) BuildPDFPayload(save []byte) (any, error) {19+ return pdfexport.ParseSudokuRGBPrintData(save)20+}21+22+func (printAdapter) RenderPDFBody(pdf *fpdf.Fpdf, payload any) error {23+ switch data := payload.(type) {24+ case *pdfexport.SudokuData:25+ renderSudokuRGBPage(pdf, data)26+ }27+ return nil28+}29+30+func renderSudokuRGBPage(pdf *fpdf.Fpdf, data *pdfexport.SudokuData) {31+ if data == nil {32+ return33+ }34+35+ pageW, pageH := pdf.GetPageSize()36+ pageNo := pdf.PageNo()37+ area := pdfexport.PuzzleBoardRect(pageW, pageH, pageNo, 1)38+ cellSize := pdfexport.FitSudokuCellSize(9, 9, area)39+ if cellSize <= 0 {40+ return41+ }42+43+ boardH := 9.0 * cellSize44+ startX, startY := pdfexport.CenteredOrigin(area, 9, 9, cellSize)45+46+ drawSudokuGridLines(pdf, startX, startY, cellSize)47+ drawSudokuRGBGivens(pdf, startX, startY, cellSize, data.Givens)48+49+ ruleY := pdfexport.InstructionY(startY+boardH, pageH, 1)50+ pdfexport.SetInstructionStyle(pdf)51+ pdf.SetXY(area.X, ruleY)52+ pdf.CellFormat(53+ area.W,54+ pdfexport.InstructionLineHMM,55+ "Fill rows, columns, and 3x3 boxes with three 1s, three 2s, and three 3s",56+ "",57+ 0,58+ "C",59+ false,60+ 0,61+ "",62+ )63+}64+65+func drawSudokuGridLines(pdf *fpdf.Fpdf, startX, startY, cellSize float64) {66+ pdf.SetDrawColor(25, 25, 25)67+68+ for i := range 10 {69+ x := startX + float64(i)*cellSize70+ pdf.SetLineWidth(sudokuLineWidthFor(i))71+ pdf.Line(x, startY, x, startY+9.0*cellSize)72+ }73+74+ for i := range 10 {75+ y := startY + float64(i)*cellSize76+ pdf.SetLineWidth(sudokuLineWidthFor(i))77+ pdf.Line(startX, y, startX+9.0*cellSize, y)78+ }79+}80+81+func sudokuLineWidthFor(index int) float64 {82+ switch {83+ case index == 0 || index == 9:84+ return 0.7285+ case index%3 == 0:86+ return 0.5687+ default:88+ return pdfexport.ThinGridLineMM89+ }90+}91+92+func drawSudokuRGBGivens(pdf *fpdf.Fpdf, startX, startY, cellSize float64, givens [9][9]int) {93+ fontSize := pdfexport.StandardCellFontSize(cellSize, 0.62)94+ lineH := fontSize * 0.8595+ pdf.SetFont(pdfexport.SansFontFamily, "B", fontSize)96+ pdf.SetTextColor(pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray, pdfexport.PrimaryTextGray)97+98+ for y := range 9 {99+ for x := range 9 {100+ value := givens[y][x]101+ if value < 1 || value > valueCount {102+ continue103+ }104+ cellX := startX + float64(x)*cellSize105+ cellY := startY + float64(y)*cellSize106+ pdf.SetXY(cellX, cellY+(cellSize-lineH)/2)107+ pdf.CellFormat(cellSize, lineH, strconv.Itoa(value), "", 0, "C", false, 0, "")108+ }109+ }110+}111+112+func init() {113+ game.RegisterPrintAdapter(printAdapter{})114+}
A sudokurgb/README.md +52 -0
A sudokurgb/cell.go +103 -0
@@ -0,0 +1,103 @@
1+package sudokurgb2+3+import "fmt"4+5+type cell struct {6+ x, y int7+ v int8+}9+10+type grid = [gridSize][gridSize]cell11+12+func newGrid(provided []cell) grid {13+ var g grid14+ for y := range gridSize {15+ for x := range gridSize {16+ g[y][x] = cell{x: x, y: y}17+ }18+ }19+ for _, hint := range provided {20+ g[hint.y][hint.x].v = hint.v21+ }22+ return g23+}24+25+func (c cell) String() string {26+ return fmt.Sprintf("r%dc%d=%d", c.y, c.x, c.v)27+}28+29+func (m Model) isSolved() bool {30+ return isSolvedWith(m.grid, m.conflicts)31+}32+33+func isSolvedWith(g grid, conflicts [gridSize][gridSize]bool) bool {34+ for y := range gridSize {35+ for x := range gridSize {36+ if g[y][x].v == 0 || conflicts[y][x] {37+ return false38+ }39+ }40+ }41+42+ for i := range gridSize {43+ if !houseHasExactQuota(rowValues(g, i)) {44+ return false45+ }46+ if !houseHasExactQuota(colValues(g, i)) {47+ return false48+ }49+ }50+51+ for box := range gridSize {52+ if !houseHasExactQuota(boxValues(g, box)) {53+ return false54+ }55+ }56+57+ return true58+}59+60+func rowValues(g grid, y int) [gridSize]int {61+ var values [gridSize]int62+ for x := range gridSize {63+ values[x] = g[y][x].v64+ }65+ return values66+}67+68+func colValues(g grid, x int) [gridSize]int {69+ var values [gridSize]int70+ for y := range gridSize {71+ values[y] = g[y][x].v72+ }73+ return values74+}75+76+func boxValues(g grid, box int) [gridSize]int {77+ var values [gridSize]int78+ boxX, boxY := (box%3)*3, (box/3)*379+ index := 080+ for dy := range 3 {81+ for dx := range 3 {82+ values[index] = g[boxY+dy][boxX+dx].v83+ index++84+ }85+ }86+ return values87+}88+89+func houseHasExactQuota(values [gridSize]int) bool {90+ var counts [valueCount + 1]int91+ for _, value := range values {92+ if value < 1 || value > valueCount {93+ return false94+ }95+ counts[value]++96+ }97+ for value := 1; value <= valueCount; value++ {98+ if counts[value] != houseQuota {99+ return false100+ }101+ }102+ return true103+}
A sudokurgb/generator.go +338 -0
@@ -0,0 +1,338 @@
1+package sudokurgb2+3+import "math/rand/v2"4+5+type quotaState struct {6+ row [gridSize][valueCount + 1]uint87+ col [gridSize][valueCount + 1]uint88+ box [gridSize][valueCount + 1]uint89+}10+11+type pos struct{ x, y int }12+13+func newRNG() *rand.Rand {14+ return rand.New(rand.NewPCG(rand.Uint64(), rand.Uint64()))15+}16+17+func boxIndex(x, y int) int {18+ return (y/3)*3 + (x / 3)19+}20+21+func buildQuotaState(g *grid) (quotaState, bool) {22+ var state quotaState23+ for i := range gridSize {24+ for value := 1; value <= valueCount; value++ {25+ state.row[i][value] = houseQuota26+ state.col[i][value] = houseQuota27+ state.box[i][value] = houseQuota28+ }29+ }30+31+ for y := range gridSize {32+ for x := range gridSize {33+ value := g[y][x].v34+ if value == 0 {35+ continue36+ }37+ if value < 1 || value > valueCount {38+ return quotaState{}, false39+ }40+41+ box := boxIndex(x, y)42+ if state.row[y][value] == 0 || state.col[x][value] == 0 || state.box[box][value] == 0 {43+ return quotaState{}, false44+ }45+46+ state.row[y][value]--47+ state.col[x][value]--48+ state.box[box][value]--49+ }50+ }51+52+ return state, true53+}54+55+func canPlace(state *quotaState, value, x, y int) bool {56+ box := boxIndex(x, y)57+ return state.row[y][value] > 0 && state.col[x][value] > 0 && state.box[box][value] > 058+}59+60+func placeValue(g *grid, state *quotaState, value, x, y int) {61+ box := boxIndex(x, y)62+ g[y][x].v = value63+ state.row[y][value]--64+ state.col[x][value]--65+ state.box[box][value]--66+}67+68+func clearValue(g *grid, state *quotaState, value, x, y int) {69+ box := boxIndex(x, y)70+ g[y][x].v = 071+ state.row[y][value]++72+ state.col[x][value]++73+ state.box[box][value]++74+}75+76+func candidateMaskForCell(state *quotaState, x, y int) uint8 {77+ var mask uint878+ for value := 1; value <= valueCount; value++ {79+ if canPlace(state, value, x, y) {80+ mask |= 1 << value81+ }82+ }83+ return mask84+}85+86+func countCandidateBits(mask uint8) int {87+ count := 088+ for value := 1; value <= valueCount; value++ {89+ if mask&(1<<value) != 0 {90+ count++91+ }92+ }93+ return count94+}95+96+func findMostConstrainedEmptyCell(g *grid, state *quotaState) (x, y int, candidates uint8, found bool) {97+ bestCount := valueCount + 198+ bestX, bestY := 0, 099+ for y := range gridSize {100+ for x := range gridSize {101+ if g[y][x].v != 0 {102+ continue103+ }104+105+ mask := candidateMaskForCell(state, x, y)106+ candidateCount := countCandidateBits(mask)107+ if candidateCount == 0 {108+ return x, y, 0, true109+ }110+ if !found || candidateCount < bestCount {111+ found = true112+ bestCount = candidateCount113+ bestX, bestY = x, y114+ candidates = mask115+ if candidateCount == 1 {116+ return bestX, bestY, candidates, true117+ }118+ }119+ }120+ }121+ return bestX, bestY, candidates, found122+}123+124+func randomCandidateOrder(mask uint8, rng *rand.Rand) []int {125+ values := make([]int, 0, valueCount)126+ for value := 1; value <= valueCount; value++ {127+ if mask&(1<<value) != 0 {128+ values = append(values, value)129+ }130+ }131+ rng.Shuffle(len(values), func(i, j int) {132+ values[i], values[j] = values[j], values[i]133+ })134+ return values135+}136+137+func fillGridRec(g *grid, rng *rand.Rand, state *quotaState) bool {138+ x, y, candidates, found := findMostConstrainedEmptyCell(g, state)139+ if !found {140+ return true141+ }142+ if candidates == 0 {143+ return false144+ }145+146+ for _, value := range randomCandidateOrder(candidates, rng) {147+ placeValue(g, state, value, x, y)148+ if fillGridRec(g, rng, state) {149+ return true150+ }151+ clearValue(g, state, value, x, y)152+ }153+154+ return false155+}156+157+func fillGrid(g *grid) bool {158+ return fillGridSeeded(g, newRNG())159+}160+161+func fillGridSeeded(g *grid, rng *rand.Rand) bool {162+ state, ok := buildQuotaState(g)163+ if !ok {164+ return false165+ }166+ return fillGridRec(g, rng, &state)167+}168+169+func countSolutions(g *grid, limit int) int {170+ if limit <= 0 {171+ return 0172+ }173+174+ state, ok := buildQuotaState(g)175+ if !ok {176+ return 0177+ }178+ return countSolutionsRec(g, limit, &state)179+}180+181+func countSolutionsRec(g *grid, limit int, state *quotaState) int {182+ if limit <= 0 {183+ return 0184+ }185+186+ x, y, candidates, found := findMostConstrainedEmptyCell(g, state)187+ if !found {188+ return 1189+ }190+ if candidates == 0 {191+ return 0192+ }193+194+ count := 0195+ for value := 1; value <= valueCount; value++ {196+ if candidates&(1<<value) == 0 {197+ continue198+ }199+ placeValue(g, state, value, x, y)200+ count += countSolutionsRec(g, limit-count, state)201+ clearValue(g, state, value, x, y)202+ if count >= limit {203+ return count204+ }205+ }206+ return count207+}208+209+func GenerateProvidedCells(mode SudokuRGBMode) []cell {210+ return GenerateProvidedCellsSeeded(mode, newRNG())211+}212+213+func clueRemovalScore(g *grid, x, y int) int {214+ score := 0215+ for i := range gridSize {216+ if i != x && g[y][i].v == 0 {217+ score++218+ }219+ if i != y && g[i][x].v == 0 {220+ score++221+ }222+ }223+224+ boxX, boxY := (x/3)*3, (y/3)*3225+ for dy := range 3 {226+ for dx := range 3 {227+ cx, cy := boxX+dx, boxY+dy228+ if (cx != x || cy != y) && g[cy][cx].v == 0 {229+ score++230+ }231+ }232+ }233+ return score234+}235+236+func pickRemovalCandidateIndex(g *grid, positions []pos, rng *rand.Rand) int {237+ bestIndex := 0238+ bestScore := clueRemovalScore(g, positions[0].x, positions[0].y)239+ for i := 1; i < len(positions); i++ {240+ score := clueRemovalScore(g, positions[i].x, positions[i].y)241+ if score < bestScore || (score == bestScore && rng.IntN(2) == 0) {242+ bestScore = score243+ bestIndex = i244+ }245+ }246+ return bestIndex247+}248+249+func collectFilledCells(g *grid) []cell {250+ cells := make([]cell, 0, gridSize*gridSize)251+ for y := range gridSize {252+ for x := range gridSize {253+ if g[y][x].v != 0 {254+ cells = append(cells, g[y][x])255+ }256+ }257+ }258+ return cells259+}260+261+func GenerateProvidedCellsSeeded(mode SudokuRGBMode, rng *rand.Rand) []cell {262+ bestCells := []cell{}263+ bestCount := gridSize*gridSize + 1264+265+ for attempt := 0; attempt < 96; attempt++ {266+ g := newGrid(nil)267+ if !fillGridSeeded(&g, rng) {268+ continue269+ }270+271+ positions := make([]pos, 0, gridSize*gridSize)272+ for y := range gridSize {273+ for x := range gridSize {274+ positions = append(positions, pos{x: x, y: y})275+ }276+ }277+ rng.Shuffle(len(positions), func(i, j int) {278+ positions[i], positions[j] = positions[j], positions[i]279+ })280+281+ filled := gridSize * gridSize282+ for len(positions) > 0 && filled > mode.ProvidedCount {283+ idx := pickRemovalCandidateIndex(&g, positions, rng)284+ p := positions[idx]285+ positions[idx] = positions[len(positions)-1]286+ positions = positions[:len(positions)-1]287+288+ saved := g[p.y][p.x].v289+ g[p.y][p.x].v = 0290+ if countSolutions(&g, 2) != 1 {291+ g[p.y][p.x].v = saved292+ continue293+ }294+ filled--295+ }296+297+ cells := collectFilledCells(&g)298+ if len(cells) == mode.ProvidedCount {299+ return cells300+ }301+ if len(cells) < bestCount {302+ bestCount = len(cells)303+ bestCells = cells304+ }305+ }306+307+ return bestCells308+}309+310+func isValid(g *grid, value, x, y int) bool {311+ if value < 1 || value > valueCount {312+ return false313+ }314+315+ rowCount, colCount, boxCount := 0, 0, 0316+ boxX, boxY := (x/3)*3, (y/3)*3317+ for i := range gridSize {318+ if i != x && g[y][i].v == value {319+ rowCount++320+ }321+ if i != y && g[i][x].v == value {322+ colCount++323+ }324+ }325+ for dy := range 3 {326+ for dx := range 3 {327+ cx, cy := boxX+dx, boxY+dy328+ if cx == x && cy == y {329+ continue330+ }331+ if g[cy][cx].v == value {332+ boxCount++333+ }334+ }335+ }336+337+ return rowCount < houseQuota && colCount < houseQuota && boxCount < houseQuota338+}
A sudokurgb/help.md +35 -0
A sudokurgb/keys.go +46 -0
A sudokurgb/mouse.go +33 -0
A sudokurgb/style.go +224 -0
@@ -0,0 +1,224 @@
1+package sudokurgb2+3+import (4+ "image/color"5+6+ "charm.land/lipgloss/v2"7+ "github.com/FelineStateMachine/puzzletea/game"8+ "github.com/FelineStateMachine/puzzletea/theme"9+)10+11+const cellWidth = game.DynamicGridCellWidth12+13+func emptyCellStyle() lipgloss.Style {14+ p := theme.Current()15+ return lipgloss.NewStyle().16+ Foreground(p.TextDim).17+ Background(game.DefaultBorderColors().BackgroundBG)18+}19+20+func providedCellStyle() lipgloss.Style {21+ return lipgloss.NewStyle().22+ Bold(true).23+ Background(game.DefaultBorderColors().BackgroundBG)24+}25+26+func userCellStyle(value int) lipgloss.Style {27+ return lipgloss.NewStyle().28+ Foreground(symbolColor(value))29+}30+31+func conflictCellStyle() lipgloss.Style {32+ p := theme.Current()33+ return lipgloss.NewStyle().34+ Foreground(p.Error).35+ Underline(true)36+}37+38+func valueCursorStyle() lipgloss.Style {39+ return lipgloss.NewStyle().40+ Bold(true)41+}42+43+func renderGrid(m Model, solved bool) string {44+ return game.RenderDynamicGrid(game.DynamicGridSpec{45+ Width: gridSize,46+ Height: gridSize,47+ Solved: solved,48+ Cell: func(x, y int) string {49+ return cellView(m, x, y, solved)50+ },51+ ZoneAt: func(x, y int) int {52+ return sudokuBoxIndex(x, y)53+ },54+ ZoneFill: func(zone int) color.Color {55+ return activeBoxZoneFill(m.cursor, solved, zone)56+ },57+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {58+ return bridgeFill(m, solved, bridge)59+ },60+ })61+}62+63+func cellView(m Model, x, y int, solved bool) string {64+ c := m.grid[y][x]65+ style := cellStyle(m, c, x, y, m.conflicts[y][x], solved)66+ text := cellContent(c)67+ if x == m.cursor.X && y == m.cursor.Y {68+ if c.v == 0 {69+ text = game.CursorLeft + "·" + game.CursorRight70+ } else {71+ text = game.CursorLeft + cellContentValue(c.v) + game.CursorRight72+ }73+ }74+75+ return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)76+}77+78+func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {79+ isCursor := m.cursor.X == x && m.cursor.Y == y80+ base := lipgloss.NewStyle()81+82+ switch c.v {83+ case 0:84+ base = emptyCellStyle()85+ default:86+ base = userCellStyle(c.v)87+ }88+89+ if m.providedGrid[y][x] {90+ base = providedCellStyle()91+ if c.v != 0 {92+ base = base.Foreground(symbolColor(c.v))93+ }94+ }95+96+ switch {97+ case conflict:98+ base = base.Inherit(conflictCellStyle())99+ case solved:100+ base = base.Bold(true)101+ }102+103+ if isCursor {104+ base = base.Inherit(valueCursorStyle())105+ }106+107+ return base108+}109+110+func bridgeFill(m Model, solved bool, bridge game.DynamicGridBridge) color.Color {111+ return nil112+}113+114+func activeBoxZoneFill(cursor game.Cursor, solved bool, zone int) color.Color {115+ return nil116+}117+118+func sudokuBoxIndex(x, y int) int {119+ return (y / 3 * 3) + (x / 3)120+}121+122+func cellContent(c cell) string {123+ if c.v == 0 {124+ return "·"125+ }126+ return cellContentValue(c.v)127+}128+129+func cellContentValue(value int) string {130+ switch value {131+ case 1:132+ return "▲"133+ case 2:134+ return "■"135+ case 3:136+ return "●"137+ default:138+ return "·"139+ }140+}141+142+func symbolColor(value int) color.Color {143+ p := theme.Current()144+ switch value {145+ case 1:146+ return p.Error147+ case 2:148+ return p.Success149+ case 3:150+ return p.Secondary151+ default:152+ return p.TextDim153+ }154+}155+156+func computeConflicts(g grid) [gridSize][gridSize]bool {157+ var conflicts [gridSize][gridSize]bool158+159+ for y := range gridSize {160+ var seen [valueCount + 1][]int161+ for x := range gridSize {162+ value := g[y][x].v163+ if value != 0 {164+ seen[value] = append(seen[value], x)165+ }166+ }167+ for value := 1; value <= valueCount; value++ {168+ if len(seen[value]) > houseQuota {169+ for _, x := range seen[value] {170+ conflicts[y][x] = true171+ }172+ }173+ }174+ }175+176+ for x := range gridSize {177+ var seen [valueCount + 1][]int178+ for y := range gridSize {179+ value := g[y][x].v180+ if value != 0 {181+ seen[value] = append(seen[value], y)182+ }183+ }184+ for value := 1; value <= valueCount; value++ {185+ if len(seen[value]) > houseQuota {186+ for _, y := range seen[value] {187+ conflicts[y][x] = true188+ }189+ }190+ }191+ }192+193+ for boxY := range 3 {194+ for boxX := range 3 {195+ type pos struct{ x, y int }196+ var seen [valueCount + 1][]pos197+ for dy := range 3 {198+ for dx := range 3 {199+ x, y := boxX*3+dx, boxY*3+dy200+ value := g[y][x].v201+ if value != 0 {202+ seen[value] = append(seen[value], pos{x: x, y: y})203+ }204+ }205+ }206+ for value := 1; value <= valueCount; value++ {207+ if len(seen[value]) > houseQuota {208+ for _, p := range seen[value] {209+ conflicts[p.y][p.x] = true210+ }211+ }212+ }213+ }214+ }215+216+ return conflicts217+}218+219+func statusBarView(showFullHelp bool) string {220+ if showFullHelp {221+ return game.StatusBarStyle().Render("mouse: click focus arrows/wasd: move 1/2/3: ▲■● bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")222+ }223+ return game.StatusBarStyle().Render("mouse: click focus 1/2/3: ▲■● bkspc: clear")224+}
A sudokurgb/sudokurgb_test.go +176 -0
@@ -0,0 +1,176 @@
1+package sudokurgb2+3+import (4+ "math/rand/v2"5+ "strings"6+ "testing"7+8+ tea "charm.land/bubbletea/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/charmbracelet/x/ansi"11+)12+13+func validCompleteGrid() grid {14+ rows := [gridSize][gridSize]int{15+ {1, 2, 3, 1, 2, 3, 1, 2, 3},16+ {2, 3, 1, 2, 3, 1, 2, 3, 1},17+ {3, 1, 2, 3, 1, 2, 3, 1, 2},18+ {1, 2, 3, 1, 2, 3, 1, 2, 3},19+ {2, 3, 1, 2, 3, 1, 2, 3, 1},20+ {3, 1, 2, 3, 1, 2, 3, 1, 2},21+ {1, 2, 3, 1, 2, 3, 1, 2, 3},22+ {2, 3, 1, 2, 3, 1, 2, 3, 1},23+ {3, 1, 2, 3, 1, 2, 3, 1, 2},24+ }25+26+ var g grid27+ for y := range gridSize {28+ for x := range gridSize {29+ g[y][x] = cell{x: x, y: y, v: rows[y][x]}30+ }31+ }32+ return g33+}34+35+func TestFillGridSeededProducesValidBoard(t *testing.T) {36+ g := newGrid(nil)37+ if !fillGridSeeded(&g, rand.New(rand.NewPCG(7, 11))) {38+ t.Fatal("fillGridSeeded returned false")39+ }40+ if conflicts := computeConflicts(g); !isSolvedWith(g, conflicts) {41+ t.Fatal("generated board is not solved")42+ }43+}44+45+func TestCountSolutionsRejectsOverQuotaState(t *testing.T) {46+ g := validCompleteGrid()47+ g[0][1].v = 148+ if got := countSolutions(&g, 2); got != 0 {49+ t.Fatalf("countSolutions(overquota) = %d, want 0", got)50+ }51+}52+53+func TestGenerateProvidedCellsSeededMatchesModeTargets(t *testing.T) {54+ for i, item := range Modes {55+ mode := item.(SudokuRGBMode)56+ t.Run(mode.Title(), func(t *testing.T) {57+ rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+101)))58+ cells := GenerateProvidedCellsSeeded(mode, rng)59+ if got, want := len(cells), mode.ProvidedCount; got != want {60+ t.Fatalf("provided count = %d, want %d", got, want)61+ }62+63+ g := newGrid(cells)64+ if got := countSolutions(&g, 2); got != 1 {65+ t.Fatalf("countSolutions() = %d, want 1", got)66+ }67+ })68+ }69+}70+71+func TestModelUpdateRestrictsInputsAndRespectsProvidedCells(t *testing.T) {72+ model := Model{73+ grid: newGrid([]cell{{x: 0, y: 0, v: 1}}),74+ provided: []cell{{x: 0, y: 0, v: 1}},75+ providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),76+ conflicts: computeConflicts(newGrid([]cell{{x: 0, y: 0, v: 1}})),77+ keys: DefaultKeyMap,78+ cursor: game.Cursor{X: 1, Y: 1},79+ }80+81+ next, _ := model.Update(tea.KeyPressMsg{Code: '1', Text: "1"})82+ got := next.(Model)83+ if got.grid[1][1].v != 1 {84+ t.Fatalf("value after 1 = %d, want 1", got.grid[1][1].v)85+ }86+87+ next, _ = got.Update(tea.KeyPressMsg{Code: '4', Text: "4"})88+ got = next.(Model)89+ if got.grid[1][1].v != 1 {90+ t.Fatalf("value after ignored 4 = %d, want 1", got.grid[1][1].v)91+ }92+93+ next, _ = got.Update(tea.KeyPressMsg{Code: tea.KeyBackspace})94+ got = next.(Model)95+ if got.grid[1][1].v != 0 {96+ t.Fatalf("value after backspace = %d, want 0", got.grid[1][1].v)97+ }98+99+ got.cursor.X, got.cursor.Y = 0, 0100+ next, _ = got.Update(tea.KeyPressMsg{Code: '2', Text: "2"})101+ got = next.(Model)102+ if got.grid[0][0].v != 1 {103+ t.Fatalf("provided cell mutated to %d, want 1", got.grid[0][0].v)104+ }105+}106+107+func TestCellViewUsesUnicodeSymbolsWithCursorMarkers(t *testing.T) {108+ g := newGrid(nil)109+ g[0][0].v = 3110+ m := Model{111+ grid: g,112+ keys: DefaultKeyMap,113+ cursor: game.Cursor{X: 0, Y: 0},114+ }115+116+ got := ansi.Strip(cellView(m, 0, 0, false))117+ if !strings.Contains(got, game.CursorLeft+"●"+game.CursorRight) {118+ t.Fatalf("filled cursor cell = %q, want marker-wrapped unicode symbol", got)119+ }120+121+ g[0][0].v = 0122+ m.grid = g123+ got = ansi.Strip(cellView(m, 0, 0, false))124+ if !strings.Contains(got, game.CursorLeft+"·"+game.CursorRight) {125+ t.Fatalf("empty cursor cell = %q, want marker-wrapped dot", got)126+ }127+}128+129+func TestScreenToGridUsesStandardCellWidth(t *testing.T) {130+ m := Model{131+ grid: newGrid(nil),132+ conflicts: computeConflicts(newGrid(nil)),133+ keys: DefaultKeyMap,134+ }135+136+ next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})137+ got := next.(Model)138+ x, y := got.cachedGridOrigin()139+140+ if col, row, ok := got.screenToGrid(x, y); !ok || col != 0 || row != 0 {141+ t.Fatalf("screenToGrid(origin) = (%d,%d,%v), want (0,0,true)", col, row, ok)142+ }143+ if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {144+ t.Fatal("screenToGrid(separator) = ok, want false")145+ }146+}147+148+func TestSaveRoundTrip(t *testing.T) {149+ g := newGrid([]cell{{x: 0, y: 0, v: 1}})150+ g[1][1].v = 3151+152+ saveModel := Model{153+ grid: g,154+ provided: []cell{{x: 0, y: 0, v: 1}},155+ providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),156+ conflicts: computeConflicts(g),157+ keys: DefaultKeyMap,158+ modeTitle: "Easy",159+ }160+161+ save, err := saveModel.GetSave()162+ if err != nil {163+ t.Fatal(err)164+ }165+166+ loaded, err := ImportModel(save)167+ if err != nil {168+ t.Fatal(err)169+ }170+ if loaded.grid[0][0].v != 1 || loaded.grid[1][1].v != 3 {171+ t.Fatalf("loaded grid values = (%d,%d), want (1,3)", loaded.grid[0][0].v, loaded.grid[1][1].v)172+ }173+ if loaded.modeTitle != "Easy" {174+ t.Fatalf("mode title = %q, want Easy", loaded.modeTitle)175+ }176+}
M weekly/weekly_test.go +15 -0