clone

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

gopls diagnostic pass. use ur linter

Dami committed b9db185
commit
b9db185a6c9bf337ded7c7b19b3a1ea36438119a
parent
09d6bea52c4262ad31bb08b365e7849595a8e1b1
author
Dami <dami.etoile@gmail.com>
55 files changed, +808 -617
MAGENTS.md1+
Mapp/category_item.go9-
Mapp/view.go12+-
Mcmd/new_export.go4+-
Mfillomino/generator.go6+-
Mfillomino/solver.go4+-
Mfillomino/style.go10+-
Mgame/border.go6+-
Mgame/dynamic_grid.go221++++++++++++++++++++++-------
Mgame/dynamic_grid_test.go99+++++++++++++
Mhashiwokakero/PrintAdapter.go4+-
Mhashiwokakero/benchmark_test.go4-
Mhashiwokakero/generation_invariants_test.go4-
Mhashiwokakero/hashiwokakero_test.go2+-
Mhashiwokakero/style.go10+-
Mhitori/PrintAdapter.go4+-
Mhitori/generator.go4+-
Mjustfile7+
Mlightsout/generator.go6+-
Mnonogram/Model.go35++---
Mnonogram/PrintAdapter.go2+-
Anonogram/dynamic_grid_test.go225++++++++++++++++++++++++++++++
Mnonogram/generator.go2+-
Mnonogram/mouse.go137++----------------
Mnonogram/nonogram_test.go208+++++++++-------------------
Mnonogram/style.go179+++++++++++++++---------
Mnurikabe/generator.go9+-
Mnurikabe/nurikabe_test.go9+-
Mnurikabe/solver.go7+-
Mpdfexport/cover_art.go10+-
Mpdfexport/parse.go2+-
Mpdfexport/printdata.go18+-
Mpdfexport/render_title.go4+-
Mregistry/registry.go2-
Mrippleeffect/generator.go2+-
Mrippleeffect/rippleeffect_test.go2+-
Mrippleeffect/style.go9+-
Mshikaku/benchmark_test.go4-
Mshikaku/generation_invariants_test.go6-
Mshikaku/style.go6+-
Mstats/layout.go1-
Mstats/stats.go4+-
Msudoku/benchmark_test.go18+-
Msudoku/dynamic_board_test.go37+----
Msudoku/uniqueness_test.go3-
Msudokurgb/generator.go2+-
Mtakuzu/PrintAdapter.go8+-
Mtakuzu/generator.go7+-
Mtakuzuplus/generator.go8+-
Mtakuzuplus/grid.go8+-
Mtakuzuplus/takuzuplus_test.go2+-
Mui/stats_view.go19+--
Mui/table.go4+-
Mui/ui_test.go2+-
Mwordsearch/Model.go7-
M AGENTS.md +1 -0
M app/category_item.go +0 -9
M app/view.go +5 -7
M cmd/new_export.go +2 -2
M fillomino/generator.go +2 -4
M fillomino/solver.go +2 -2
M fillomino/style.go +4 -6
M game/border.go +2 -4
M game/dynamic_grid.go +165 -56
@@ -10,14 +10,12 @@ import (
1010 1111 const DynamicGridCellWidth = 31212 13-const (14- dynamicGridCellStepY = 215-)16-1713 type DynamicGridMetrics struct {18- Width int19- Height int20- CellWidth int14+ Width int15+ Height int16+ CellWidth int17+ VerticalBridgeWidth func(x int) int18+ HorizontalBridgeHeight func(y int) int2119 }2220 2321 type DynamicGridBridgeKind int
@@ -39,29 +37,39 @@ type DynamicGridBridge struct {
3937 }4038 4139 type DynamicGridSpec struct {42- Width int43- Height int44- CellWidth int45- Solved bool46- Colors *GridBorderColors47- Cell func(x, y int) string48- ZoneAt func(x, y int) int49- HasVerticalEdge func(x, y int) bool50- HasHorizontalEdge func(x, y int) bool51- ZoneFill func(zone int) color.Color52- BridgeFill func(bridge DynamicGridBridge) color.Color53- BridgeForeground func(bridge DynamicGridBridge) color.Color54- BridgeBold func(bridge DynamicGridBridge) bool55- VerticalBridgeText func(x, y int) string56- HorizontalBridgeText func(x, y int) string40+ Width int41+ Height int42+ CellWidth int43+ Solved bool44+ Colors *GridBorderColors45+ Cell func(x, y int) string46+ ZoneAt func(x, y int) int47+ HasVerticalEdge func(x, y int) bool48+ HasHorizontalEdge func(x, y int) bool49+ VerticalBridgeWidth func(x int) int50+ HorizontalBridgeHeight func(y int) int51+ ZoneFill func(zone int) color.Color52+ BridgeFill func(bridge DynamicGridBridge) color.Color53+ BridgeForeground func(bridge DynamicGridBridge) color.Color54+ BridgeBold func(bridge DynamicGridBridge) bool55+ VerticalBridgeText func(x, y int) string56+ HorizontalBridgeText func(x, y int) string5757 }5858 5959 func RenderDynamicGrid(spec DynamicGridSpec) string {60- rows := make([]string, 0, spec.Height*dynamicGridCellStepY+1)61- rows = append(rows, dynamicGridBoundaryRow(spec, 0))62- for y := range spec.Height {60+ rows := make([]string, 0, spec.Height*2+1)61+ for y := 0; y <= spec.Height; y++ {62+ rowHeight := dynamicGridHorizontalBridgeHeight(spec, y)63+ if rowHeight > 0 {64+ row := dynamicGridBoundaryRow(spec, y)65+ for range rowHeight {66+ rows = append(rows, row)67+ }68+ }69+ if y == spec.Height {70+ continue71+ }6372 rows = append(rows, dynamicGridContentRow(spec, y))64- rows = append(rows, dynamicGridBoundaryRow(spec, y+1))6573 }6674 return lipgloss.JoinVertical(lipgloss.Left, rows...)6775 }
@@ -113,21 +121,30 @@ func DynamicGridScreenToCell(
113121 return 0, 0, false114122 }115123 116- maxX := (metrics.Width-1)*(cellWidth+1) + (cellWidth - 1)117- maxY := (metrics.Height - 1) * dynamicGridCellStepY118- if lx > maxX || ly > maxY {124+ col, ok = dynamicGridLocateAxis(125+ lx,126+ metrics.Width,127+ cellWidth,128+ includeSeparators,129+ func(x int) int {130+ return dynamicGridMetricVerticalBridgeWidth(metrics, x)131+ },132+ )133+ if !ok {119134 return 0, 0, false120135 }121136 122- if includeSeparators {123- col = min((lx+cellWidth/2)/(cellWidth+1), metrics.Width-1)124- row = min((ly+1)/dynamicGridCellStepY, metrics.Height-1)125- } else {126- col = lx / (cellWidth + 1)127- row = ly / dynamicGridCellStepY128- if lx%(cellWidth+1) >= cellWidth || ly%dynamicGridCellStepY != 0 {129- return 0, 0, false130- }137+ row, ok = dynamicGridLocateAxis(138+ ly,139+ metrics.Height,140+ 1,141+ includeSeparators,142+ func(y int) int {143+ return dynamicGridMetricHorizontalBridgeHeight(metrics, y)144+ },145+ )146+ if !ok {147+ return 0, 0, false131148 }132149 133150 if col < 0 || col >= metrics.Width || row < 0 || row >= metrics.Height {
@@ -180,13 +197,11 @@ func dynamicGridContentRow(spec DynamicGridSpec, y int) string {
180197 func dynamicGridBoundaryRow(spec DynamicGridSpec, y int) string {181198 var b strings.Builder182199 for x := 0; x <= spec.Width; x++ {183- runeValue := dynamicGridJunctionRune(spec, x, y)184- b.WriteString(dynamicGridRenderBorderChar(185- runeValue,186- dynamicGridColors(spec),187- spec.Solved,188- dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),189- ))200+ junctionWidth := dynamicGridVerticalBridgeWidth(spec, x)201+ if junctionWidth > 0 {202+ runeValue := dynamicGridJunctionRune(spec, x, y)203+ b.WriteString(dynamicGridRenderJunction(spec, x, y, junctionWidth, runeValue))204+ }190205 if x == spec.Width {191206 continue192207 }
@@ -212,12 +227,20 @@ func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool,
212227 }213228 214229 func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {230+ bridgeWidth := dynamicGridVerticalBridgeWidth(spec, x)231+ if bridgeWidth <= 0 {232+ return ""233+ }234+215235 if dynamicGridHasVerticalEdge(spec, x, y) {216- return dynamicGridRenderBorderChar(217- verticalEdgeRune(true),218- dynamicGridColors(spec),219- spec.Solved,220- dynamicGridBridgeBackground(spec, bridge),236+ return strings.Repeat(237+ dynamicGridRenderBorderChar(238+ verticalEdgeRune(true),239+ dynamicGridColors(spec),240+ spec.Solved,241+ dynamicGridBridgeBackground(spec, bridge),242+ ),243+ bridgeWidth,221244 )222245 }223246
@@ -225,7 +248,7 @@ func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge Dyna
225248 if text := strings.TrimSpace(spec.VerticalBridgeText(x, y)); text != "" {226249 return dynamicGridRenderBridgeText(227250 text,228- 1,251+ bridgeWidth,229252 dynamicGridColors(spec),230253 spec.Solved,231254 dynamicGridBridgeBackground(spec, bridge),
@@ -235,11 +258,14 @@ func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge Dyna
235258 }236259 }237260 238- return dynamicGridRenderBorderChar(239- verticalEdgeRune(false),240- dynamicGridColors(spec),241- spec.Solved,242- dynamicGridBridgeBackground(spec, bridge),261+ return strings.Repeat(262+ dynamicGridRenderBorderChar(263+ verticalEdgeRune(false),264+ dynamicGridColors(spec),265+ spec.Solved,266+ dynamicGridBridgeBackground(spec, bridge),267+ ),268+ bridgeWidth,243269 )244270 }245271
@@ -333,6 +359,22 @@ func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {
333359 }334360 }335361 362+func dynamicGridRenderJunction(spec DynamicGridSpec, x, y, width int, runeValue rune) string {363+ if width <= 0 {364+ return ""365+ }366+367+ return dynamicGridRenderBridgeText(368+ string(runeValue),369+ width,370+ dynamicGridColors(spec),371+ spec.Solved,372+ dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),373+ nil,374+ false,375+ )376+}377+336378 func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {337379 north := y > 0 && dynamicGridHasVerticalEdge(spec, x, y-1)338380 south := y < spec.Height && dynamicGridHasVerticalEdge(spec, x, y)
@@ -393,6 +435,45 @@ func dynamicGridBridgeBold(spec DynamicGridSpec, bridge DynamicGridBridge) bool
393435 return false394436 }395437 438+func dynamicGridLocateAxis(439+ offset, count, cellSpan int,440+ includeSeparators bool,441+ bridgeSize func(index int) int,442+) (index int, ok bool) {443+ if count <= 0 || cellSpan <= 0 {444+ return 0, false445+ }446+447+ position := 0448+ for i := range count {449+ cellEnd := position + cellSpan450+ if offset < cellEnd {451+ return i, true452+ }453+454+ position = cellEnd455+ if i == count-1 {456+ break457+ }458+459+ size := max(bridgeSize(i+1), 0)460+ if size <= 0 {461+ continue462+ }463+464+ bridgeEnd := position + size465+ if offset < bridgeEnd {466+ if !includeSeparators {467+ return 0, false468+ }469+ return i + 1, true470+ }471+ position = bridgeEnd472+ }473+474+ return 0, false475+}476+396477 func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {397478 if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {398479 return DynamicGridBridge{
@@ -489,6 +570,34 @@ func dynamicGridZoneAt(spec DynamicGridSpec, x, y int) int {
489570 return spec.ZoneAt(x, y)490571 }491572 573+func dynamicGridVerticalBridgeWidth(spec DynamicGridSpec, x int) int {574+ if spec.VerticalBridgeWidth == nil {575+ return 1576+ }577+ return max(spec.VerticalBridgeWidth(x), 0)578+}579+580+func dynamicGridHorizontalBridgeHeight(spec DynamicGridSpec, y int) int {581+ if spec.HorizontalBridgeHeight == nil {582+ return 1583+ }584+ return max(spec.HorizontalBridgeHeight(y), 0)585+}586+587+func dynamicGridMetricVerticalBridgeWidth(metrics DynamicGridMetrics, x int) int {588+ if metrics.VerticalBridgeWidth == nil {589+ return 1590+ }591+ return max(metrics.VerticalBridgeWidth(x), 0)592+}593+594+func dynamicGridMetricHorizontalBridgeHeight(metrics DynamicGridMetrics, y int) int {595+ if metrics.HorizontalBridgeHeight == nil {596+ return 1597+ }598+ return max(metrics.HorizontalBridgeHeight(y), 0)599+}600+492601 func verticalEdgeRune(hasEdge bool) rune {493602 if hasEdge {494603 return '│'
M game/dynamic_grid_test.go +99 -0
@@ -109,6 +109,48 @@ func TestRenderDynamicGridSupportsCustomCellWidth(t *testing.T) {
109109 }110110 }111111 112+func TestRenderDynamicGridCollapsesSparseBridges(t *testing.T) {113+ view := RenderDynamicGrid(DynamicGridSpec{114+ Width: 2,115+ Height: 2,116+ Cell: func(_, _ int) string {117+ return " "118+ },119+ ZoneAt: func(_, _ int) int {120+ return 0121+ },122+ HasVerticalEdge: func(x, _ int) bool {123+ return x <= 0 || x >= 2124+ },125+ HasHorizontalEdge: func(_, y int) bool {126+ return y <= 0 || y >= 2127+ },128+ VerticalBridgeWidth: func(x int) int {129+ if x <= 0 || x >= 2 {130+ return 1131+ }132+ return 0133+ },134+ HorizontalBridgeHeight: func(y int) int {135+ if y <= 0 || y >= 2 {136+ return 1137+ }138+ return 0139+ },140+ })141+142+ lines := strings.Split(ansi.Strip(view), "\n")143+ if got, want := len(lines), 4; got != want {144+ t.Fatalf("line count = %d, want %d", got, want)145+ }146+ if got, want := lines[0], "┌──────┐"; got != want {147+ t.Fatalf("top row = %q, want %q", got, want)148+ }149+ if got := strings.Count(lines[1], "│"); got != 2 {150+ t.Fatalf("content row border count = %d, want 2", got)151+ }152+}153+112154 func TestRenderDynamicGridEdgeOverridesSuppressInteriorBorders(t *testing.T) {113155 view := RenderDynamicGrid(DynamicGridSpec{114156 Width: 2,
@@ -232,6 +274,63 @@ func TestDynamicGridScreenToCell(t *testing.T) {
232274 }233275 }234276 277+func TestDynamicGridScreenToCellSupportsSparseBridges(t *testing.T) {278+ metrics := DynamicGridMetrics{279+ Width: 3,280+ Height: 3,281+ CellWidth: DynamicGridCellWidth,282+ VerticalBridgeWidth: func(x int) int {283+ if x == 2 {284+ return 1285+ }286+ return 0287+ },288+ HorizontalBridgeHeight: func(y int) int {289+ if y == 2 {290+ return 1291+ }292+ return 0293+ },294+ }295+ originX, originY := 10, 20296+297+ tests := []struct {298+ name string299+ screenX int300+ screenY int301+ includeSeparators bool302+ wantCol int303+ wantRow int304+ wantOK bool305+ }{306+ {"flush next cell", originX + DynamicGridCellWidth, originY, false, 1, 0, true},307+ {"major vertical separator rejected", originX + 2*DynamicGridCellWidth, originY, false, 0, 0, false},308+ {"major vertical separator snaps", originX + 2*DynamicGridCellWidth, originY, true, 2, 0, true},309+ {"major horizontal separator rejected", originX, originY + 2, false, 0, 0, false},310+ {"major horizontal separator snaps", originX, originY + 2, true, 0, 2, true},311+ {"outside right", originX + 3*DynamicGridCellWidth + 1, originY, false, 0, 0, false},312+ }313+314+ for _, tt := range tests {315+ t.Run(tt.name, func(t *testing.T) {316+ col, row, ok := DynamicGridScreenToCell(317+ metrics,318+ originX,319+ originY,320+ tt.screenX,321+ tt.screenY,322+ tt.includeSeparators,323+ )324+ if ok != tt.wantOK {325+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)326+ }327+ if ok && (col != tt.wantCol || row != tt.wantRow) {328+ t.Fatalf("cell = (%d,%d), want (%d,%d)", col, row, tt.wantCol, tt.wantRow)329+ }330+ })331+ }332+}333+235334 func TestDynamicGridBridgeOnCrosshairAxis(t *testing.T) {236335 cursor := Cursor{X: 2, Y: 1}237336
M hashiwokakero/PrintAdapter.go +2 -2
M hashiwokakero/benchmark_test.go +0 -4
M hashiwokakero/generation_invariants_test.go +0 -4
M hashiwokakero/hashiwokakero_test.go +1 -1
M hashiwokakero/style.go +2 -8
M hitori/PrintAdapter.go +2 -2
M hitori/generator.go +1 -3
M justfile +7 -0
M lightsout/generator.go +3 -3
M nonogram/Model.go +15 -20
@@ -4,10 +4,11 @@ package nonogram
44 import (55 "errors"66 "fmt"7+ "strconv"8+ "strings"79 810 "charm.land/bubbles/v2/key"911 tea "charm.land/bubbletea/v2"10- "charm.land/lipgloss/v2"1112 "github.com/FelineStateMachine/puzzletea/game"1213 )1314
@@ -137,7 +138,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
137138 case tea.MouseClickMsg:138139 m.lastMouseX, m.lastMouseY = msg.X, msg.Y139140 m.lastMouseBtn = msg.String()140- col, row, ok := m.screenToGrid(msg.X, msg.Y)141+ col, row, ok := m.screenToGrid(msg.X, msg.Y, false)141142 m.lastMouseGridCol, m.lastMouseGridRow = col, row142143 m.lastMouseHit = ok143144 if m.solved || !ok {
@@ -157,7 +158,7 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
157158 if m.solved || m.dragging == 0 {158159 break159160 }160- col, row, ok := m.screenToGrid(msg.X, msg.Y)161+ col, row, ok := m.screenToGrid(msg.X, msg.Y, true)161162 if !ok {162163 break163164 }
@@ -177,24 +178,18 @@ func (m Model) Update(msg tea.Msg) (game.Gamer, tea.Cmd) {
177178 }178179 179180 func (m Model) View() string {180- maxWidth, maxHeight := m.rowHints.RequiredLen()*cellWidth, m.colHints.RequiredLen()181-182181 title := game.TitleBarView("Nonogram", m.modeTitle, m.solved)183- g := gridView(m.grid, m.cursor, m.solved)184- r := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)185- c := colHintView(m.colHints, maxHeight, m.currentHints.cols)186- spacer := lipgloss.NewStyle().Width(maxWidth).Height(maxHeight).Render("")182+ board := buildBoardBlock(m)187183 status := statusBarView(m.showFullHelp)188-189- s1 := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, c)190- s2 := lipgloss.JoinHorizontal(lipgloss.Top, r, g)191-192- grid := lipgloss.JoinVertical(lipgloss.Center, s1, s2)193184 if m.solved {194- return game.ComposeGameView(title, grid)185+ return game.ComposeGameView(title, board.Block)195186 }196187 197- return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))188+ return game.ComposeGameViewRows(189+ title,190+ board.Block,191+ game.StableRow(status, statusBarView(false), statusBarView(true)),192+ )198193 }199194 200195 func (m Model) GetDebugInfo() string {
@@ -247,14 +242,14 @@ func intSliceStr(s []int) string {
247242 if len(s) == 0 {248243 return "[]"249244 }250- result := ""245+ var b strings.Builder251246 for i, v := range s {252247 if i > 0 {253- result += ", "248+ b.WriteString(", ")254249 }255- result += fmt.Sprintf("%d", v)250+ b.WriteString(strconv.Itoa(v))256251 }257- return result252+ return b.String()258253 }259254 260255 func (m Model) SetTitle(t string) game.Gamer {
M nonogram/PrintAdapter.go +1 -1
A nonogram/dynamic_grid_test.go +225 -0
@@ -0,0 +1,225 @@
1+package nonogram2+3+import (4+ "image/color"5+ "strings"6+ "testing"7+8+ "charm.land/lipgloss/v2"9+ "github.com/FelineStateMachine/puzzletea/game"10+ "github.com/FelineStateMachine/puzzletea/theme"11+ "github.com/charmbracelet/x/ansi"12+)13+14+func TestGridViewUsesOnlyOuterBordersAndFiveCellSeparators(t *testing.T) {15+ g := newGrid(createEmptyState(10, 10))16+17+ lines := strings.Split(ansi.Strip(gridView(g, game.Cursor{X: 4, Y: 4}, false)), "\n")18+ if got, want := len(lines), 13; got != want {19+ t.Fatalf("line count = %d, want %d", got, want)20+ }21+ if got, want := lipgloss.Width(lines[0]), 33; got != want {22+ t.Fatalf("top row width = %d, want %d", got, want)23+ }24+ wantTop := "┌" + strings.Repeat("─", cellWidth*5) + "┬" + strings.Repeat("─", cellWidth*5) + "┐"25+ if got := lines[0]; got != wantTop {26+ t.Fatalf("top row = %q, want %q", got, wantTop)27+ }28+29+ contentRow := []rune(lines[1])30+ if got := contentRow[0]; got != '│' {31+ t.Fatalf("left border = %q, want %q", got, '│')32+ }33+ if got := contentRow[16]; got != '│' {34+ t.Fatalf("five-cell separator = %q, want %q", got, '│')35+ }36+ if got := contentRow[32]; got != '│' {37+ t.Fatalf("right border = %q, want %q", got, '│')38+ }39+ if got := strings.Count(lines[1], "│"); got != 3 {40+ t.Fatalf("content row border count = %d, want 3", got)41+ }42+43+ wantDivider := "├" + strings.Repeat("─", cellWidth*5) + "┼" + strings.Repeat("─", cellWidth*5) + "┤"44+ if got := lines[6]; got != wantDivider {45+ t.Fatalf("major divider = %q, want %q", got, wantDivider)46+ }47+}48+49+func TestSeparatorBridgeFillHighlightsOnlySpacerEdgesOnAxis(t *testing.T) {50+ cursor := game.Cursor{X: 4, Y: 4}51+ surface := theme.Current().Surface52+53+ tests := []struct {54+ name string55+ bridge game.DynamicGridBridge56+ want color.Color57+ }{58+ {59+ name: "vertical spacer on cursor row",60+ bridge: game.DynamicGridBridge{61+ Kind: game.DynamicGridBridgeVertical,62+ X: 5,63+ Y: 4,64+ },65+ want: surface,66+ },67+ {68+ name: "horizontal spacer on cursor column",69+ bridge: game.DynamicGridBridge{70+ Kind: game.DynamicGridBridgeHorizontal,71+ X: 4,72+ Y: 5,73+ },74+ want: surface,75+ },76+ {77+ name: "normal interior bridge stays empty",78+ bridge: game.DynamicGridBridge{79+ Kind: game.DynamicGridBridgeVertical,80+ X: 1,81+ Y: 4,82+ },83+ want: nil,84+ },85+ {86+ name: "outer border stays empty",87+ bridge: game.DynamicGridBridge{88+ Kind: game.DynamicGridBridgeHorizontal,89+ X: 4,90+ Y: 0,91+ },92+ want: nil,93+ },94+ }95+96+ for _, tt := range tests {97+ t.Run(tt.name, func(t *testing.T) {98+ if got := separatorBridgeFill(cursor, false, 10, 10, tt.bridge); !game.SameColor(got, tt.want) {99+ t.Fatalf("separatorBridgeFill() = %v, want %v", got, tt.want)100+ }101+ })102+ }103+104+ if got := separatorBridgeFill(cursor, true, 10, 10, game.DynamicGridBridge{105+ Kind: game.DynamicGridBridgeVertical,106+ X: 5,107+ Y: 4,108+ }); got != nil {109+ t.Fatalf("solved separatorBridgeFill() = %v, want nil", got)110+ }111+}112+113+func TestBuildBoardBlockPreservesHintBandLayout(t *testing.T) {114+ m := Model{115+ width: 5,116+ height: 5,117+ rowHints: TomographyDefinition{{1}, {2}, {3}, {4}, {5}},118+ colHints: TomographyDefinition{{1}, {2}, {3}, {4}, {5}},119+ grid: newGrid(createEmptyState(5, 5)),120+ cursor: game.Cursor{X: 0, Y: 0},121+ }122+123+ layout := buildBoardBlock(m)124+ lines := strings.Split(ansi.Strip(layout.Block), "\n")125+ if len(lines) <= layout.HintHeight {126+ t.Fatalf("block height = %d, want > hint height %d", len(lines), layout.HintHeight)127+ }128+ if got := lipgloss.Width(lines[layout.HintHeight]); got <= layout.HintWidth {129+ t.Fatalf("content row width = %d, want > hint width %d", got, layout.HintWidth)130+ }131+ if got := []rune(lines[layout.HintHeight])[0]; got != ' ' {132+ t.Fatalf("row hint should render outside the grid border, got %q", got)133+ }134+ if got := []rune(lines[layout.HintHeight])[layout.HintWidth]; got != '┌' {135+ t.Fatalf("grid top border should start after hint band, got %q", got)136+ }137+ if got := []rune(lines[layout.HintHeight+1])[layout.HintWidth]; got != '│' {138+ t.Fatalf("grid content should remain offset after hint band, got %q", got)139+ }140+}141+142+func TestBuildBoardBlockAlignsHintSeparatorsWithGrid(t *testing.T) {143+ m := Model{144+ width: 10,145+ height: 10,146+ rowHints: repeatedHints(10, []int{1}),147+ colHints: repeatedHints(10, []int{1}),148+ grid: newGrid(createEmptyState(10, 10)),149+ currentHints: Hints{rows: repeatedHints(10, []int{1}), cols: repeatedHints(10, []int{1})},150+ }151+152+ layout := buildBoardBlock(m)153+ lines := strings.Split(ansi.Strip(layout.Block), "\n")154+ separatorColumn := layout.HintWidth + 1 + cellWidth*5155+ if got := []rune(lines[layout.HintHeight-1])[separatorColumn]; got != '│' {156+ t.Fatalf("column hint separator = %q, want %q", got, '│')157+ }158+ if got := []rune(lines[layout.HintHeight])[separatorColumn]; got != '┬' {159+ t.Fatalf("top border separator = %q, want %q", got, '┬')160+ }161+ if got := []rune(lines[layout.HintHeight+6])[separatorColumn]; got != '┼' {162+ t.Fatalf("row divider separator = %q, want %q", got, '┼')163+ }164+}165+166+func TestBuildBoardBlockDimensionsAcrossModes(t *testing.T) {167+ tests := []struct {168+ name string169+ size int170+ }{171+ {name: "5x5", size: 5},172+ {name: "10x10", size: 10},173+ {name: "15x15", size: 15},174+ {name: "20x20", size: 20},175+ }176+177+ for _, tt := range tests {178+ t.Run(tt.name, func(t *testing.T) {179+ hints := repeatedHints(tt.size, []int{1})180+ m := Model{181+ width: tt.size,182+ height: tt.size,183+ rowHints: hints,184+ colHints: hints,185+ grid: newGrid(createEmptyState(tt.size, tt.size)),186+ currentHints: Hints{rows: hints, cols: hints},187+ }188+189+ layout := buildBoardBlock(m)190+ if layout.HintWidth <= 0 || layout.HintHeight <= 0 {191+ t.Fatalf("hint bands should be non-zero, got (%d,%d)", layout.HintWidth, layout.HintHeight)192+ }193+ if lipgloss.Width(layout.Block) < layout.HintWidth+lipgloss.Width(layout.Grid) {194+ t.Fatalf("block width = %d, want at least %d", lipgloss.Width(layout.Block), layout.HintWidth+lipgloss.Width(layout.Grid))195+ }196+ if lipgloss.Height(layout.Block) != layout.HintHeight+lipgloss.Height(layout.Grid) {197+ t.Fatalf("block height = %d, want %d", lipgloss.Height(layout.Block), layout.HintHeight+lipgloss.Height(layout.Grid))198+ }199+ })200+ }201+}202+203+func TestHintViewsUseSatisfiedStyle(t *testing.T) {204+ rowHints := TomographyDefinition{{3}, {1, 1}}205+ rowView := rowHintView(rowHints, 5, rowHints)206+ wantRow := hintSatisfiedStyle().Width(5).Align(lipgloss.Right).Render(" 3")207+ if !strings.Contains(rowView, wantRow) {208+ t.Fatalf("rowHintView() = %q, want satisfied row %q", rowView, wantRow)209+ }210+211+ colHints := TomographyDefinition{{2}, {1}}212+ colView := colHintView(colHints, 1, colHints)213+ wantCol := hintSatisfiedStyle().Width(cellWidth).Align(lipgloss.Center).Render("2")214+ if !strings.Contains(colView, wantCol) {215+ t.Fatalf("colHintView() = %q, want satisfied column %q", colView, wantCol)216+ }217+}218+219+func repeatedHints(size int, hint []int) TomographyDefinition {220+ hints := make(TomographyDefinition, size)221+ for i := range size {222+ hints[i] = append([]int(nil), hint...)223+ }224+ return hints225+}
M nonogram/generator.go +1 -1
M nonogram/mouse.go +15 -122
@@ -1,141 +1,34 @@
11 package nonogram22 3-import (4- "strings"3+import "github.com/FelineStateMachine/puzzletea/game"54 6- "charm.land/lipgloss/v2"7- "github.com/FelineStateMachine/puzzletea/game"8-)9-10-// screenToGrid converts terminal screen coordinates to grid cell coordinates.11-// Returns (col, row, ok) where ok is false if the click landed outside the grid12-// or on a separator.13-func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {5+func (m *Model) screenToGrid(screenX, screenY int, includeSeparators bool) (col, row int, ok bool) {146 ox, oy := m.cachedGridOrigin()15- lx := screenX - ox16- ly := screenY - oy17- if lx < 0 || ly < 0 {18- return 0, 0, false19- }20-21- col = localToCellX(lx, m.width)22- row = localToCellY(ly, m.height)23- if col < 0 || col >= m.width || row < 0 || row >= m.height {24- return 0, 0, false25- }26-27- return col, row, true28-}29-30-// localToCellX converts a local horizontal offset within the grid area to a31-// column index, accounting for spacer separators every spacerEvery cells.32-// Each cell is cellWidth (3) characters wide; separators are 1 char wide.33-// Returns -1 if the position falls on a separator or out of bounds.34-func localToCellX(pos, count int) int {35- return localToCell(pos, count, cellWidth)7+ board := buildBoardBlock(*m)8+ return game.DynamicGridScreenToCell(9+ board.Metrics,10+ ox,11+ oy,12+ screenX,13+ screenY,14+ includeSeparators,15+ )3616 }3717 38-// localToCellY converts a local vertical offset within the grid area to a39-// row index. Each row is 1 terminal line tall; separators are 1 line tall.40-// Returns -1 if the position falls on a separator or out of bounds.41-func localToCellY(pos, count int) int {42- return localToCell(pos, count, 1)43-}44-45-// localToCell is the generic conversion for both axes.46-// unitWidth is the visual width of one cell (cellWidth for X, 1 for Y).47-func localToCell(pos, count, unitWidth int) int {48- if count <= spacerEvery {49- cell := pos / unitWidth50- if cell >= count {51- return -152- }53- return cell54- }55-56- blockWidth := spacerEvery*unitWidth + 157- block := pos / blockWidth58- rem := pos % blockWidth59-60- cellInBlock := rem / unitWidth61- if cellInBlock >= spacerEvery {62- return -163- }64-65- cell := block*spacerEvery + cellInBlock66- if cell >= count {67- return -168- }69- return cell70-}71-72-// cachedGridOrigin returns the screen position of the top-left corner of73-// cell (0,0), using a cached value when available. The cache is invalidated74-// on terminal resize and solve-state changes.7518 func (m *Model) cachedGridOrigin() (x, y int) {7619 if m.originValid {7720 return m.originX, m.originY7821 }22+7923 x, y = m.gridOrigin()8024 m.originX, m.originY = x, y8125 m.originValid = true8226 return x, y8327 }8428 85-// gridOrigin computes the screen position of the top-left corner of the86-// first grid cell. Mouse coordinates are terminal-absolute, so we need to87-// account for how the root model centers the game's View() output.88-//89-// The root view does:90-//91-// lipgloss.Place(termWidth, termHeight, Center, Center, gameView)92-//93-// So the centering offset is (termWidth - viewWidth) / 2 for X and94-// (termHeight - viewHeight) / 2 for Y.95-//96-// Within the game view, the grid cell (0,0) is offset by the row hint97-// area width (X) and the title + column hint area height (Y).9829 func (m *Model) gridOrigin() (x, y int) {99- // Render the game view to get its exact dimensions.100- gameView := m.View()101- viewWidth := lipgloss.Width(gameView)102- viewHeight := lipgloss.Height(gameView)103-104- // The root view caps the game view to terminal width before centering.105- if viewWidth > m.termWidth {106- viewWidth = m.termWidth107- }108-109- // Centering offset applied by the root's lipgloss.Place().110- centerX := max((m.termWidth-viewWidth)/2, 0)111- centerY := max((m.termHeight-viewHeight)/2, 0)112-113- // Within the game view, find where cell (0,0) starts.114- // Render the subcomponents to measure their sizes.115- maxWidth := m.rowHints.RequiredLen() * cellWidth116- maxHeight := m.colHints.RequiredLen()117-11830 title := game.TitleBarView("Nonogram", m.modeTitle, m.solved)119- rowHints := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)120- colHints := colHintView(m.colHints, maxHeight, m.currentHints.cols)121- spacer := lipgloss.NewStyle().Width(maxWidth).Height(maxHeight).Render("")122-123- s1 := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, colHints)124- gridBlock := lipgloss.JoinVertical(lipgloss.Center, s1, "placeholder")125-126- // The grid block may be narrower than the full view (title or status127- // could be wider). JoinVertical(Center) pads to the widest line.128- gridBlockWidth := lipgloss.Width(gridBlock)129- gridBlockPadLeft := max((viewWidth-gridBlockWidth)/2, 0)130-131- // The row hint width within the grid block.132- hintAreaWidth := lipgloss.Width(rowHints)133-134- // Vertical: title lines + column hint area lines.135- titleHeight := strings.Count(title, "\n") + 1136- colHintAreaHeight := strings.Count(s1, "\n") + 1137-138- x = centerX + gridBlockPadLeft + hintAreaWidth139- y = centerY + titleHeight + colHintAreaHeight140- return x, y31+ board := buildBoardBlock(*m)32+ x, y = game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, board.Block)33+ return x + board.HintWidth, y + board.HintHeight14134 }
M nonogram/nonogram_test.go +64 -144
@@ -911,7 +911,6 @@ func TestGenerateRandomTomography_LargeModes_Unique(t *testing.T) {
911911 }912912 913913 for _, tc := range cases {914- tc := tc915914 t.Run(tc.name, func(t *testing.T) {916915 rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))917916 hints := GenerateRandomTomographySeeded(tc.mode, rng)
@@ -952,7 +951,6 @@ func TestGenerateRandomTomography_LargeModes_PerfBudget(t *testing.T) {
952951 }953952 954953 for _, tc := range cases {955- tc := tc956954 t.Run(tc.name, func(t *testing.T) {957955 start := time.Now()958956 rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))
@@ -997,14 +995,13 @@ func TestSpawnPerformance(t *testing.T) {
997995 998996 t.Run("average spawn time across all modes", func(t *testing.T) {999997 for _, cfg := range modeConfigs {1000- cfg := cfg1001998 t.Run(cfg.name, func(t *testing.T) {1002999 if cfg.mode.Width >= 15 && testing.Short() {10031000 t.Skip("skipping large grids in short mode")10041001 }10051002 10061003 var totalDuration time.Duration1007- for i := 0; i < numRuns; i++ {1004+ for range numRuns {10081005 start := time.Now()10091006 _, err := cfg.mode.Spawn()10101007 if err != nil {
@@ -1057,173 +1054,98 @@ func TestReset(t *testing.T) {
10571054 }10581055 }10591056 1060-// --- localToCell (P0) ---1061-1062-func TestLocalToCellX(t *testing.T) {1063- t.Run("small grid no spacers", func(t *testing.T) {1064- // 5 cells, cellWidth=3, no separators.1065- tests := []struct {1066- pos int1067- want int1068- }{1069- {0, 0}, {1, 0}, {2, 0}, // cell 01070- {3, 1}, {4, 1}, {5, 1}, // cell 11071- {6, 2}, {7, 2}, {8, 2}, // cell 21072- {9, 3}, {10, 3}, {11, 3}, // cell 31073- {12, 4}, {13, 4}, {14, 4}, // cell 41074- {15, -1}, // out of bounds1075- }1076- for _, tt := range tests {1077- got := localToCellX(tt.pos, 5)1078- if got != tt.want {1079- t.Errorf("localToCellX(%d, 5) = %d, want %d", tt.pos, got, tt.want)1080- }1081- }1082- })1083-1084- t.Run("10 cells with one spacer", func(t *testing.T) {1085- // Cells 0-4, separator, cells 5-9.1086- // Block width = 5*3+1 = 16.1087- tests := []struct {1088- pos int1089- want int1090- }{1091- {0, 0}, // cell 0 start1092- {2, 0}, // cell 0 end1093- {3, 1}, // cell 1 start1094- {12, 4}, // cell 4 start1095- {14, 4}, // cell 4 end1096- {15, -1}, // separator1097- {16, 5}, // cell 5 start1098- {18, 5}, // cell 5 end1099- {19, 6}, // cell 6 start1100- {28, 9}, // cell 9 start1101- {30, 9}, // cell 9 end1102- {31, -1}, // out of bounds1103- }1104- for _, tt := range tests {1105- got := localToCellX(tt.pos, 10)1106- if got != tt.want {1107- t.Errorf("localToCellX(%d, 10) = %d, want %d", tt.pos, got, tt.want)1108- }1109- }1110- })1111-1112- t.Run("15 cells with two spacers", func(t *testing.T) {1113- // Cells 0-4, sep, 5-9, sep, 10-14.1114- // Total = 15*3 + 2 separators = 47 characters.1115- tests := []struct {1116- pos int1117- want int1118- }{1119- {0, 0},1120- {15, -1}, // first separator1121- {16, 5}, // cell 51122- {31, -1}, // second separator1123- {32, 10}, // cell 101124- {44, 14}, // cell 141125- {46, 14}, // still cell 14 (chars 44-46)1126- }1127- for _, tt := range tests {1128- got := localToCellX(tt.pos, 15)1129- if got != tt.want {1130- t.Errorf("localToCellX(%d, 15) = %d, want %d", tt.pos, got, tt.want)1131- }1132- }1133- })1134-}1057+// --- screenToGrid (P0) ---11351058 1136-func TestLocalToCellY(t *testing.T) {1137- t.Run("small grid no spacers", func(t *testing.T) {1138- for i := range 5 {1139- got := localToCellY(i, 5)1140- if got != i {1141- t.Errorf("localToCellY(%d, 5) = %d, want %d", i, got, i)1142- }1059+func TestScreenToGrid(t *testing.T) {1060+ buildModel := func(width, height int) Model {1061+ mode := NonogramMode{1062+ BaseMode: game.NewBaseMode("Test", "grid"),1063+ Width: width,1064+ Height: height,11431065 }1144- if got := localToCellY(5, 5); got != -1 {1145- t.Errorf("localToCellY(5, 5) = %d, want -1", got)1066+ hints := Hints{1067+ rows: repeatedHints(height, []int{1}),1068+ cols: repeatedHints(width, []int{1}),11461069 }1147- })1148-1149- t.Run("10 rows with one spacer", func(t *testing.T) {1150- // Rows 0-4, separator at 5, rows 5-9 at positions 6-10.1151- tests := []struct {1152- pos int1153- want int1154- }{1155- {0, 0},1156- {4, 4},1157- {5, -1}, // separator1158- {6, 5},1159- {10, 9},1160- {11, -1}, // out of bounds1161- }1162- for _, tt := range tests {1163- got := localToCellY(tt.pos, 10)1164- if got != tt.want {1165- t.Errorf("localToCellY(%d, 10) = %d, want %d", tt.pos, got, tt.want)1166- }1070+ g, err := New(mode, hints)1071+ if err != nil {1072+ t.Fatal(err)11671073 }1168- })1169-}1170-1171-// --- screenToGrid (P0) ---1172-1173-func TestScreenToGrid(t *testing.T) {1174- // Build a 5x5 model with known hint sizes and terminal dimensions.1175- mode := NonogramMode{1176- BaseMode: game.NewBaseMode("Test", "5x5"),1177- Width: 5,1178- Height: 5,1179- }1180- hints := Hints{1181- rows: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1182- cols: TomographyDefinition{{1}, {1}, {1}, {1}, {1}},1183- }1184- g, err := New(mode, hints)1185- if err != nil {1186- t.Fatal(err)1074+ m := g.(Model)1075+ m.termWidth = 801076+ m.termHeight = 401077+ return m11871078 }1188- m := g.(Model)1189- m.termWidth = 801190- m.termHeight = 4011911079 1192- ox, oy := m.gridOrigin()1080+ t.Run("basic 5x5 hit-testing", func(t *testing.T) {1081+ m := buildModel(5, 5)1082+ ox, oy := m.gridOrigin()11931083 1194- t.Run("first cell", func(t *testing.T) {1195- col, row, ok := m.screenToGrid(ox, oy)1084+ col, row, ok := m.screenToGrid(ox, oy, false)11961085 if !ok {11971086 t.Fatal("expected ok for first cell")11981087 }11991088 if col != 0 || row != 0 {12001089 t.Errorf("got (%d,%d), want (0,0)", col, row)12011090 }1202- })12031091 1204- t.Run("last cell", func(t *testing.T) {1205- col, row, ok := m.screenToGrid(ox+4*cellWidth, oy+4)1092+ col, row, ok = m.screenToGrid(ox+4*cellWidth, oy+4, false)12061093 if !ok {12071094 t.Fatal("expected ok for last cell")12081095 }12091096 if col != 4 || row != 4 {12101097 t.Errorf("got (%d,%d), want (4,4)", col, row)12111098 }1212- })12131099 1214- t.Run("outside grid left", func(t *testing.T) {1215- _, _, ok := m.screenToGrid(ox-1, oy)1100+ _, _, ok = m.screenToGrid(ox-1, oy, false)12161101 if ok {12171102 t.Error("expected not ok for click left of grid")12181103 }1219- })12201104 1221- t.Run("outside grid above", func(t *testing.T) {1222- _, _, ok := m.screenToGrid(ox, oy-1)1105+ _, _, ok = m.screenToGrid(ox, oy-1, false)12231106 if ok {12241107 t.Error("expected not ok for click above grid")12251108 }12261109 })1110+1111+ t.Run("10x10 separators and hint bands", func(t *testing.T) {1112+ m := buildModel(10, 10)1113+ ox, oy := m.gridOrigin()1114+1115+ separatorX := ox + cellWidth*51116+ separatorY := oy + 51117+1118+ _, _, ok := m.screenToGrid(separatorX, oy, false)1119+ if ok {1120+ t.Error("expected separator click to miss")1121+ }1122+1123+ col, row, ok := m.screenToGrid(separatorX, oy, true)1124+ if !ok {1125+ t.Fatal("expected separator drag to snap")1126+ }1127+ if col != 5 || row != 0 {1128+ t.Errorf("got (%d,%d), want (5,0)", col, row)1129+ }1130+1131+ _, _, ok = m.screenToGrid(ox, separatorY, false)1132+ if ok {1133+ t.Error("expected row separator click to miss")1134+ }1135+1136+ col, row, ok = m.screenToGrid(ox, separatorY, true)1137+ if !ok {1138+ t.Fatal("expected row separator drag to snap")1139+ }1140+ if col != 0 || row != 5 {1141+ t.Errorf("got (%d,%d), want (0,5)", col, row)1142+ }1143+1144+ _, _, ok = m.screenToGrid(ox-cellWidth, oy, false)1145+ if ok {1146+ t.Error("expected hint band click to miss")1147+ }1148+ })12271149 }12281150 12291151 func TestHelpToggleInvalidatesOriginCache(t *testing.T) {
@@ -1281,7 +1203,6 @@ func BenchmarkGenerateRandomTomographyModes(b *testing.B) {
12811203 }12821204 12831205 for _, cfg := range modeConfigs {1284- cfg := cfg12851206 b.Run(cfg.name, func(b *testing.B) {12861207 b.ReportAllocs()12871208 for n := 0; n < b.N; n++ {
@@ -1317,7 +1238,6 @@ func BenchmarkCountSolutionsLarge(b *testing.B) {
13171238 }13181239 13191240 for _, tc := range cases {1320- tc := tc13211241 b.Run(tc.name, func(b *testing.B) {13221242 rng := rand.New(rand.NewPCG(tc.seedA, tc.seedB))13231243 hints := GenerateRandomTomographySeeded(tc.mode, rng)
M nonogram/style.go +115 -64
@@ -11,7 +11,7 @@ import (
1111 )1212 1313 const (14- cellWidth = 314+ cellWidth = game.DynamicGridCellWidth1515 spacerEvery = 51616 )1717
@@ -66,29 +66,6 @@ func needsSpacer(i, n int) bool {
6666 return n > spacerEvery && (i+1)%spacerEvery == 0 && i < n-16767 }6868 69-// hSeparator builds a horizontal separator row using box-drawing characters.70-// w is the number of grid columns. cursorX is the cursor column (-1 to disable crosshair).71-// bg is the default background, crossBG is the crosshair-highlighted background.72-func hSeparator(w, cursorX int, bg, crossBG color.Color) string {73- p := theme.Current()74- defStyle := lipgloss.NewStyle().Foreground(p.Border).Background(bg)75- highStyle := lipgloss.NewStyle().Foreground(p.Border).Background(crossBG)76- segment := strings.Repeat("\u2500", cellWidth)77-78- var parts []string79- for x := range w {80- style := defStyle81- if x == cursorX {82- style = highStyle83- }84- parts = append(parts, style.Render(segment))85- if needsSpacer(x, w) {86- parts = append(parts, defStyle.Render("\u253c"))87- }88- }89- return lipgloss.JoinHorizontal(lipgloss.Top, parts...)90-}91-9269 func colHintView(c TomographyDefinition, height int, current ...TomographyDefinition) string {9370 var hasCurrent bool9471 var curr TomographyDefinition
@@ -176,56 +153,130 @@ func rowHintView(r TomographyDefinition, width int, current ...TomographyDefinit
176153 return s177154 }178155 179-func gridView(g grid, c game.Cursor, solved bool) string {180- p := theme.Current()156+type boardBlockLayout struct {157+ Block string158+ Grid string159+ HintWidth int160+ HintHeight int161+ Metrics game.DynamicGridMetrics162+}181163 182- h := len(g)183- w := 0184- if h > 0 {185- w = len(g[0])164+func nonogramMetrics(width, height int) game.DynamicGridMetrics {165+ return game.DynamicGridMetrics{166+ Width: width,167+ Height: height,168+ CellWidth: cellWidth,169+ VerticalBridgeWidth: func(x int) int {170+ return nonogramVerticalBridgeWidth(width, x)171+ },172+ HorizontalBridgeHeight: func(y int) int {173+ return nonogramHorizontalBridgeHeight(height, y)174+ },186175 }176+}187177 188- sepStyle := lipgloss.NewStyle().Foreground(p.Border)178+func nonogramVerticalBridgeWidth(width, x int) int {179+ switch {180+ case x <= 0, x >= width:181+ return 1182+ case needsSpacer(x-1, width):183+ return 1184+ default:185+ return 0186+ }187+}189188 190- // Determine background for horizontal separators (matches grid background).191- gridBG := p.BG192- crosshairBG := p.Surface189+func nonogramHorizontalBridgeHeight(height, y int) int {190+ switch {191+ case y <= 0, y >= height:192+ return 1193+ case needsSpacer(y-1, height):194+ return 1195+ default:196+ return 0197+ }198+}199+200+func buildBoardBlock(m Model) boardBlockLayout {201+ maxWidth := m.rowHints.RequiredLen() * cellWidth202+ maxHeight := m.colHints.RequiredLen()203+ metrics := nonogramMetrics(m.width, m.height)204+205+ grid := gridView(m.grid, m.cursor, m.solved)206+ rowHints := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)207+ colHints := colHintView(m.colHints, maxHeight, m.currentHints.cols)208+ spacer := lipgloss.NewStyle().Width(maxWidth).Height(maxHeight).Render("")209+ topBand := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, colHints)210+ block := lipgloss.JoinVertical(211+ lipgloss.Center,212+ topBand,213+ lipgloss.JoinHorizontal(lipgloss.Top, rowHints, grid),214+ )215+216+ return boardBlockLayout{217+ Block: block,218+ Grid: grid,219+ HintWidth: lipgloss.Width(rowHints),220+ HintHeight: lipgloss.Height(topBand),221+ Metrics: metrics,222+ }223+}224+225+func gridView(g grid, c game.Cursor, solved bool) string {226+ height := len(g)227+ width := 0228+ if height > 0 {229+ width = len(g[0])230+ }231+ metrics := nonogramMetrics(width, height)232+233+ return game.RenderDynamicGrid(game.DynamicGridSpec{234+ Width: width,235+ Height: height,236+ CellWidth: cellWidth,237+ Solved: solved,238+ VerticalBridgeWidth: metrics.VerticalBridgeWidth,239+ HorizontalBridgeHeight: metrics.HorizontalBridgeHeight,240+ Cell: func(x, y int) string {241+ return tileView(g[y][x], x == c.X && y == c.Y, y == c.Y, x == c.X, solved)242+ },243+ ZoneAt: func(_, _ int) int {244+ return 0245+ },246+ HasVerticalEdge: func(x, _ int) bool {247+ return x <= 0 || x >= width || needsSpacer(x-1, width)248+ },249+ HasHorizontalEdge: func(_, y int) bool {250+ return y <= 0 || y >= height || needsSpacer(y-1, height)251+ },252+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {253+ return separatorBridgeFill(c, solved, width, height, bridge)254+ },255+ })256+}257+258+func separatorBridgeFill(cursor game.Cursor, solved bool, width, height int, bridge game.DynamicGridBridge) color.Color {193259 if solved {194- gridBG = p.SuccessBG260+ return nil195261 }196262 197- var rows []string198- for y, row := range g {199- inCursorRow := y == c.Y200-201- var rowBuilder []string202- for x, cell := range row {203- isCursor := x == c.X && y == c.Y204- inCursorCol := x == c.X205- cell := tileView(cell, isCursor, inCursorRow, inCursorCol, solved)206- rowBuilder = append(rowBuilder, cell)207-208- if needsSpacer(x, w) {209- bg := gridBG210- if !solved && inCursorRow {211- bg = crosshairBG212- }213- rowBuilder = append(rowBuilder, sepStyle.Background(bg).Render("\u2502"))214- }263+ switch bridge.Kind {264+ case game.DynamicGridBridgeVertical:265+ if bridge.X <= 0 || bridge.X >= width || !needsSpacer(bridge.X-1, width) {266+ return nil215267 }216- row := lipgloss.JoinHorizontal(lipgloss.Top, rowBuilder...)217- rows = append(rows, row)218-219- if needsSpacer(y, h) {220- cursorX := -1221- if !solved {222- cursorX = c.X223- }224- rows = append(rows, hSeparator(w, cursorX, gridBG, crosshairBG))268+ case game.DynamicGridBridgeHorizontal:269+ if bridge.Y <= 0 || bridge.Y >= height || !needsSpacer(bridge.Y-1, height) {270+ return nil225271 }272+ default:273+ return nil274+ }275+276+ if game.DynamicGridBridgeOnCrosshairAxis(cursor, bridge) {277+ return theme.Current().Surface226278 }227- grid := lipgloss.JoinVertical(lipgloss.Left, rows...)228- return grid279+ return nil229280 }230281 231282 func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string {
M nurikabe/generator.go +2 -7
M nurikabe/nurikabe_test.go +3 -6
M nurikabe/solver.go +3 -4
M pdfexport/cover_art.go +5 -5
@@ -294,7 +294,7 @@ func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {
294294 })295295 296296 glowCount := 2 + structureRNG.Intn(3)297- for i := 0; i < glowCount; i++ {297+ for i := range glowCount {298298 col := palette.accentA299299 if i%2 == 1 {300300 col = palette.accentB
@@ -337,7 +337,7 @@ func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {
337337 338338 flowLayers := make([]coverFlowLayer, 0, 4)339339 layerCount := 2 + structureRNG.Intn(3)340- for i := 0; i < layerCount; i++ {340+ for i := range layerCount {341341 t := float64(i) / float64(maxInt(1, layerCount-1))342342 a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24)343343 b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24)
@@ -799,7 +799,7 @@ func buildCoverLatticeNodes(
799799 ringT := float64(ring) / float64(rings+1)800800 radius := ringT * minDim * (0.16 + 0.62*ringT)801801 offset := rng.Float64() * math.Pi * 2802- for spoke := 0; spoke < spokes; spoke++ {802+ for spoke := range spokes {803803 ang := offset + float64(spoke)/float64(spokes)*math.Pi*2804804 ang += math.Sin(float64(ring)*0.9+float64(spoke)*0.45) * profile.radialWarp805805 jitter := (rng.Float64() - 0.5) * profile.jitterX
@@ -810,7 +810,7 @@ func buildCoverLatticeNodes(
810810 }811811 case 2:812812 total := maxInt(38, profile.cols*profile.rows)813- for i := 0; i < total; i++ {813+ for i := range total {814814 tx := (rng.Float64()*0.84 + 0.08)815815 ty := (rng.Float64()*0.84 + 0.08)816816 warpX := math.Sin(ty*math.Pi*4+float64(i)*0.22) * profile.radialWarp * 0.12
@@ -855,7 +855,7 @@ func drawCoverOrbitBands(
855855 t := float64(i) / float64(maxInt(1, direction.orbit.count-1))856856 col := lerpRGB(direction.orbit.colorA, direction.orbit.colorB, t)857857 col.A = minUint8(220, direction.orbit.alphaBase+uint8(i)*direction.orbit.alphaStep)858- for s := 0; s < segments; s++ {858+ for s := range segments {859859 a := start + float64(s)/float64(segments)*sweep860860 jx := math.Sin(float64(i)*0.43+a*2.9) * direction.orbit.wobble861861 jy := math.Cos(float64(i)*0.37+a*2.3) * direction.orbit.wobble * 0.82
M pdfexport/parse.go +1 -1
M pdfexport/printdata.go +8 -10
@@ -544,9 +544,7 @@ func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {
544544 }545545 546546 height := save.Height547- if len(rows) > height {548- height = len(rows)549- }547+ height = max(height, len(rows))550548 if width <= 0 || height <= 0 {551549 return nil, nil552550 }
@@ -591,7 +589,7 @@ func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {
591589 func parseNumberGrid(encoded string, width, height int) ([][]int, error) {592590 rows := strings.Split(strings.TrimSpace(encoded), "\n")593591 grid := make([][]int, height)594- for y := 0; y < height; y++ {592+ for y := range height {595593 grid[y] = make([]int, width)596594 if y >= len(rows) {597595 continue
@@ -604,7 +602,7 @@ func parseNumberGrid(encoded string, width, height int) ([][]int, error) {
604602 if len(fields) != width {605603 return nil, fmt.Errorf("decode number grid: invalid row width")606604 }607- for x := 0; x < width; x++ {605+ for x := range width {608606 if fields[x] == "." {609607 continue610608 }
@@ -621,7 +619,7 @@ func parseNumberGrid(encoded string, width, height int) ([][]int, error) {
621619 func parseProvidedMask(encoded string, width, height int) [][]bool {622620 rows := strings.Split(encoded, "\n")623621 mask := make([][]bool, height)624- for y := 0; y < height; y++ {622+ for y := range height {625623 mask[y] = make([]bool, width)626624 if y >= len(rows) {627625 continue
@@ -711,7 +709,7 @@ func parseNurikabeClues(raw string, width, height int) ([][]int, error) {
711709 }712710 713711 clues := make([][]int, height)714- for y := 0; y < height; y++ {712+ for y := range height {715713 clues[y] = make([]int, width)716714 }717715
@@ -761,7 +759,7 @@ func normalizeNonogramHintRows(src [][]int, size int) [][]int {
761759 }762760 763761 normalized := make([][]int, size)764- for i := 0; i < size; i++ {762+ for i := range size {765763 if i >= len(src) {766764 normalized[i] = []int{0}767765 continue
@@ -788,9 +786,9 @@ func normalizeNonogramStateGrid(rows []string, width, height int) [][]string {
788786 }789787 790788 grid := make([][]string, height)791- for y := 0; y < height; y++ {789+ for y := range height {792790 grid[y] = make([]string, width)793- for x := 0; x < width; x++ {791+ for x := range width {794792 grid[y][x] = " "795793 }796794
M pdfexport/render_title.go +1 -3
M registry/registry.go +0 -2
M rippleeffect/generator.go +1 -1
M rippleeffect/rippleeffect_test.go +1 -1
M rippleeffect/style.go +3 -6
M shikaku/benchmark_test.go +0 -4
M shikaku/generation_invariants_test.go +0 -6
M shikaku/style.go +3 -3
M stats/layout.go +0 -1
M stats/stats.go +1 -3
M sudoku/benchmark_test.go +10 -8
@@ -28,8 +28,6 @@ func BenchmarkGenerateProvidedCellsByMode(b *testing.B) {
2828 skipBenchmarkInShortMode(b)2929 3030 for modeIndex, mode := range benchmarkSudokuModes() {31- mode := mode32- modeIndex := modeIndex3331 b.Run(mode.Title(), func(b *testing.B) {3432 b.ReportAllocs()3533 for i := 0; i < b.N; i++ {
@@ -47,8 +45,6 @@ func BenchmarkCountSolutionsByDifficulty(b *testing.B) {
4745 skipBenchmarkInShortMode(b)4846 4947 for modeIndex, mode := range benchmarkSudokuModes() {50- mode := mode51- modeIndex := modeIndex5248 b.Run(mode.Title(), func(b *testing.B) {5349 rng := rand.New(rand.NewPCG(uint64(modeIndex+1), uint64(modeIndex+2)))5450 cells := GenerateProvidedCellsSeeded(mode, rng)
@@ -75,12 +71,14 @@ func BenchmarkGenerateProvidedCellsSeededStable(b *testing.B) {
7571 7672 mode := NewMode("Hard", "benchmark", 27)7773 b.ReportAllocs()78- for i := 0; i < b.N; i++ {74+ i := 075+ for b.Loop() {7976 rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+100)))8077 cells := GenerateProvidedCellsSeeded(mode, rng)8178 if len(cells) < mode.ProvidedCount {8279 b.Fatalf("generated %d clues, want >= %d", len(cells), mode.ProvidedCount)8380 }81+ i++8482 }8583 }8684
@@ -96,12 +94,14 @@ func BenchmarkCountSolutionsFixedPuzzle(b *testing.B) {
9694 9795 b.ReportAllocs()9896 b.ResetTimer()99- for i := 0; i < b.N; i++ {97+ i := 098+ for b.Loop() {10099 g := source101100 count := countSolutions(&g, 2)102101 if count != 1 {103102 b.Fatalf("iteration %d: countSolutions=%d, want 1", i, count)104103 }104+ i++105105 }106106 }107107
@@ -114,7 +114,7 @@ func BenchmarkIsValidAllCells(b *testing.B) {
114114 115115 b.ReportAllocs()116116 b.ResetTimer()117- for i := 0; i < b.N; i++ {117+ for b.Loop() {118118 for y := range gridSize {119119 for x := range gridSize {120120 val := g[y][x].v
@@ -134,7 +134,8 @@ func BenchmarkCountSolutionsModesSummary(b *testing.B) {
134134 135135 modes := benchmarkSudokuModes()136136 b.ReportAllocs()137- for i := 0; i < b.N; i++ {137+ i := 0138+ for b.Loop() {138139 mode := modes[i%len(modes)]139140 rng := rand.New(rand.NewPCG(uint64(i+1), uint64(i+2)))140141 cells := GenerateProvidedCellsSeeded(mode, rng)
@@ -142,6 +143,7 @@ func BenchmarkCountSolutionsModesSummary(b *testing.B) {
142143 if got := countSolutions(&g, 2); got != 1 {143144 b.Fatalf("mode %s uniqueness check failed: %d", mode.Title(), got)144145 }146+ i++145147 }146148 }147149
M sudoku/dynamic_board_test.go +6 -31
@@ -123,46 +123,21 @@ func TestZoneFillHighlightsOnlyActiveBox(t *testing.T) {
123123 m := Model{cursor: game.Cursor{X: 1, Y: 1}}124124 surface := theme.Current().Surface125125 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) {126+ inBoxZone := 0127+ if got := activeBoxZoneFill(m.cursor, false, inBoxZone); !sameColor(got, surface) {136128 t.Fatalf("in-box bridge background = %v, want %v", got, surface)137129 }138130 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) {131+ if got := activeBoxZoneFill(m.cursor, false, inBoxZone); !sameColor(got, surface) {149132 t.Fatalf("in-box junction background = %v, want %v", got, surface)150133 }151134 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 {135+ outOfBoxZone := 1136+ if got := activeBoxZoneFill(m.cursor, false, outOfBoxZone); got != nil {162137 t.Fatalf("out-of-box bridge background = %v, want nil", got)163138 }164139 165- if got := activeBoxZoneFill(m.cursor, true, inBox.Zone); got != nil {140+ if got := activeBoxZoneFill(m.cursor, true, inBoxZone); got != nil {166141 t.Fatalf("solved in-box bridge background = %v, want nil", got)167142 }168143 }
M sudoku/uniqueness_test.go +0 -3
M sudokurgb/generator.go +1 -1
M takuzu/PrintAdapter.go +4 -4
M takuzu/generator.go +4 -3
M takuzuplus/generator.go +4 -4
M takuzuplus/grid.go +4 -4
M takuzuplus/takuzuplus_test.go +1 -1
M ui/stats_view.go +4 -15
M ui/table.go +1 -3
M ui/ui_test.go +1 -1
M wordsearch/Model.go +0 -7