- commit
- b9db185a6c9bf337ded7c7b19b3a1ea36438119a
- parent
- 09d6bea52c4262ad31bb08b365e7849595a8e1b1
- author
- Dami <dami.etoile@gmail.com>
@@ -4,7 +4,6 @@ import (
44 "strings"55 66 "charm.land/bubbles/v2/list"7- "github.com/FelineStateMachine/puzzletea/puzzle"87 "github.com/FelineStateMachine/puzzletea/registry"98 )109 @@ -36,11 +35,3 @@ func selectedCategoryEntry(item list.Item) (registry.Entry, bool) {
3635 }3736 return category.entry, true3837 }39-40-func categoryEntryByName(name string) (registry.Entry, bool) {41- return registry.Resolve(name)42-}43-44-func normalizeCategoryName(name string) string {45- return puzzle.NormalizeName(name)46-}@@ -135,14 +135,14 @@ func (m model) gameSelectViewContent() string {
135135 listView := lipgloss.NewStyle().136136 Width(metrics.listWidth).137137 Height(metrics.listHeight).138+ Border(lipgloss.NormalBorder(), false, true, false, false).139+ BorderForeground(theme.Current().Border).138140 Render(m.nav.gameSelectList.View())139141 140142 detailBox := lipgloss.NewStyle().141143 Width(metrics.detailWidth).142144 Height(metrics.detailHeight).143- Border(lipgloss.RoundedBorder()).144- BorderForeground(theme.Current().Border).145- Padding(1, 2).145+ Padding(2, 3).146146 Render(m.nav.categoryDetail.View())147147 148148 body := lipgloss.JoinHorizontal(lipgloss.Top, listView, strings.Repeat(" ", categoryGapWidth), detailBox)@@ -157,7 +157,7 @@ func (m model) gameSelectViewContent() string {
157157 panel := ui.Panel(158158 "Select Category",159159 body,160- "↑/↓ navigate • pgup/pgdn details • / filter • enter select • esc back",160+ "↑/↓ navigate • / filter • enter select • esc back",161161 )162162 return ui.CenterView(m.width, m.height, panel)163163 }@@ -289,9 +289,7 @@ func (m model) themeSelectViewContent() string {
289289 // Panel chrome: border (2) + padding (2) + title (1) + blank (1) + footer (1) + blank (1) = 8290290 const panelChrome = 8291291 innerH := m.height - panelChrome292- if innerH < 10 {293- innerH = 10294- }292+ innerH = max(innerH, 10)295293 296294 // Left side: theme list.297295 listView := m.theme.list.View()@@ -62,7 +62,7 @@ func runNewExport(cmd *cobra.Command, args []string) error {
6262 return nil6363 }6464 65-func validateNewExportFlags(cmd *cobra.Command, args []string) error {65+func validateNewExportFlags(_ *cobra.Command, args []string) error {6666 if flagExport < 1 {6767 return fmt.Errorf("--export must be at least 1")6868 }@@ -123,7 +123,7 @@ func buildExportRecords(
123123 nameRNG := resolve.RNGFromString("export-names:" + nameSeed)124124 125125 records := make([]pdfexport.JSONLRecord, 0, count)126- for i := 0; i < count; i++ {126+ for i := range count {127127 entry := entries[0]128128 if len(entries) > 1 {129129 var modeIndex int@@ -24,7 +24,7 @@ func GeneratePuzzle(width, height, maxRegion int, givenRatio float64) (Puzzle, e
2424 2525 func GeneratePuzzleSeeded(width, height, maxRegion int, givenRatio float64, rng *rand.Rand) (Puzzle, error) {2626 const maxAttempts = 1227- for attempt := 0; attempt < maxAttempts; attempt++ {27+ for range maxAttempts {2828 solution, _, err := generateSolution(width, height, maxRegion, rng)2929 if err != nil {3030 continue@@ -169,9 +169,7 @@ func removeClues(givens grid, maxRegion int, givenRatio float64, rng *rand.Rand)
169169 width := len(givens[0])170170 height := len(givens)171171 target := int(float64(width*height) * givenRatio)172- if target < 1 {173- target = 1174- }172+ target = max(target, 1)175173 176174 cells := make([]point, 0, width*height)177175 for y := range height {@@ -47,7 +47,7 @@ func validateGridState(g grid) validationResult {
4747 }4848 4949 comp := buildComponent(g, point{x: x, y: y}, visited)50- markComponentConflicts(g, comp, complete, conflicts)50+ markComponentConflicts(comp, complete, conflicts)5151 }5252 }5353 @@ -97,7 +97,7 @@ func buildComponent(g grid, start point, visited [][]bool) component {
9797 return comp9898 }9999 100-func markComponentConflicts(g grid, comp component, assumeComplete bool, conflicts [][]bool) {100+func markComponentConflicts(comp component, assumeComplete bool, conflicts [][]bool) {101101 size := len(comp.cells)102102 value := comp.value103103 if value <= 0 {@@ -171,7 +171,7 @@ func bridgeTouchesBorder(renderState renderGridState, bridge game.DynamicGridBri
171171 }172172 }173173 174-func zoneHorizontalEdge(zones [][]int, width, height, x, y int) bool {174+func zoneHorizontalEdge(zones [][]int, height, x, y int) bool {175175 switch {176176 case y <= 0, y >= height:177177 return true@@ -193,8 +193,8 @@ func zoneVerticalEdge(zones [][]int, width, height, x, y int) bool {
193193 func zoneJunctionRune(zones [][]int, width, height, x, y int) rune {194194 north := y > 0 && zoneVerticalEdge(zones, width, height, x, y-1)195195 south := y < height && zoneVerticalEdge(zones, width, height, x, y)196- west := x > 0 && zoneHorizontalEdge(zones, width, height, x-1, y)197- east := x < width && zoneHorizontalEdge(zones, width, height, x, y)196+ west := x > 0 && zoneHorizontalEdge(zones, height, x-1, y)197+ east := x < width && zoneHorizontalEdge(zones, height, x, y)198198 199199 switch {200200 case north && south && west && east:@@ -308,9 +308,7 @@ func cursorRegionInfoView(m Model) string {
308308 func cursorRegionInfoVariants(m Model) []string {309309 area := m.width * m.height310310 maxTarget := m.maxCellValue311- if maxTarget < 1 {312- maxTarget = 1313- }311+ maxTarget = max(maxTarget, 1)314312 315313 return []string{316314 cursorRegionInfoStyle().Render("region size: " + strconv.Itoa(area) + "/-"),@@ -2,6 +2,7 @@ package game
22 33 import (44 "image/color"5+ "strings"56 67 "charm.land/lipgloss/v2"78 "github.com/FelineStateMachine/puzzletea/theme"@@ -64,10 +65,7 @@ func BorderChar(ch string, colors GridBorderColors, solved, highlight bool) stri
6465 func HBorderRow(w, cursorX, cellWidth int, left, right string, colors GridBorderColors, solved bool) string {6566 var parts []string6667 parts = append(parts, BorderChar(left, colors, solved, false))67- segment := ""68- for range cellWidth {69- segment += "─"70- }68+ segment := strings.Repeat("─", cellWidth)7169 for x := range w {7270 highlight := !solved && x == cursorX7371 bg := color.Color(nil)@@ -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 '│'@@ -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 @@ -72,8 +72,8 @@ func renderHashiPage(pdf *fpdf.Fpdf, data *pdfexport.HashiData) {
7272 func drawHashiGuideDots(pdf *fpdf.Fpdf, originX, originY float64, width, height int, step float64) {7373 pdf.SetFillColor(230, 230, 230)7474 r := math.Max(0.20, math.Min(0.55, step*0.035))75- for y := 0; y < height; y++ {76- for x := 0; x < width; x++ {75+ for y := range height {76+ for x := range width {7777 cx := originX + float64(x)*step7878 cy := originY + float64(y)*step7979 pdf.Circle(cx, cy, r, "F")@@ -25,8 +25,6 @@ func BenchmarkGeneratePuzzleByBoardSize(b *testing.B) {
2525 skipBenchmarkInShortMode(b)2626 2727 for modeIndex, mode := range benchmarkHashiModesByBoardSize() {28- mode := mode29- modeIndex := modeIndex3028 b.Run(mode.Title(), func(b *testing.B) {3129 b.ReportAllocs()3230 for i := 0; i < b.N; i++ {@@ -56,8 +54,6 @@ func BenchmarkBuildSpanningTree(b *testing.B) {
5654 skipBenchmarkInShortMode(b)5755 5856 for modeIndex, mode := range benchmarkHashiModesByBoardSize() {59- mode := mode60- modeIndex := modeIndex6157 b.Run(mode.Title(), func(b *testing.B) {6258 base, err := GeneratePuzzleSeeded(mode, rand.New(rand.NewPCG(uint64(modeIndex+50), uint64(modeIndex+60))))6359 if err != nil {@@ -92,9 +92,6 @@ func assertNoBridgeCrossings(t *testing.T, p *Puzzle) {
9292 9393 func TestGeneratePuzzleRequiredCountsByBoardSize(t *testing.T) {9494 for modeIndex, mode := range benchmarkHashiModesByBoardSize() {95- mode := mode96- modeIndex := modeIndex97-9895 t.Run(mode.Title(), func(t *testing.T) {9996 puzzle, err := GeneratePuzzleSeeded(mode, rand.New(rand.NewPCG(uint64(modeIndex+900), uint64(modeIndex+901))))10097 if err != nil {@@ -117,7 +114,6 @@ func TestGeneratePuzzleRequiredCountsByBoardSize(t *testing.T) {
117114 118115 func TestBridgeLayoutConstraintInvariantsByBoardSize(t *testing.T) {119116 for _, mode := range benchmarkHashiModesByBoardSize() {120- mode := mode121117 t.Run(mode.Title(), func(t *testing.T) {122118 p := testBridgeLayoutByMode(t, mode)123119 @@ -1211,7 +1211,7 @@ func TestPlaceIslands(t *testing.T) {
12111211 if islands == nil {12121212 continue12131213 }1214- for i := 0; i < len(islands); i++ {1214+ for i := range len(islands) {12151215 for j := i + 1; j < len(islands); j++ {12161216 dx := abs(islands[i].X - islands[j].X)12171217 dy := abs(islands[i].Y - islands[j].Y)@@ -3,6 +3,7 @@ package hashiwokakero
33 import (44 "fmt"55 "image/color"6+ "slices"67 "strings"78 89 "charm.land/lipgloss/v2"@@ -331,14 +332,7 @@ func dynamicBridgeIdentity(p *Puzzle, bridge game.DynamicGridBridge) (int, int)
331332 if cell.Kind != cellIsland {332333 continue333334 }334- duplicate := false335- for _, id := range ids {336- if id == cell.IslandID {337- duplicate = true338- break339- }340- }341- if !duplicate {335+ if !slices.Contains(ids, cell.IslandID) {342336 ids = append(ids, cell.IslandID)343337 }344338 }@@ -45,8 +45,8 @@ func renderHitoriPage(pdf *fpdf.Fpdf, data *pdfexport.HitoriData) {
4545 4646 pdf.SetDrawColor(55, 55, 55)4747 pdf.SetLineWidth(pdfexport.ThinGridLineMM)48- for y := 0; y < size; y++ {49- for x := 0; x < size; x++ {48+ for y := range size {49+ for x := range size {5050 cellX := startX + float64(x)*cellSize5151 cellY := startY + float64(y)*cellSize5252 pdf.Rect(cellX, cellY, cellSize, cellSize, "D")@@ -13,15 +13,15 @@ func Generate(w, h int) [][]bool {
1313 1414 func GenerateSeeded(w, h int, rng *rand.Rand) [][]bool {1515 grid := make([][]bool, h)16- for y := 0; y < h; y++ {16+ for y := range h {1717 grid[y] = make([]bool, w)1818 }1919 2020 // Iterate through every cell and randomly decide whether to "click" it.2121 // Since clicking a cell twice cancels out, and the order of clicks doesn't matter,2222 // iterating once through all cells covers all reachable states from "all off".23- for y := 0; y < h; y++ {24- for x := 0; x < w; x++ {23+ for y := range h {24+ for x := range w {2525 if rng.IntN(2) == 1 {2626 Toggle(grid, x, y)2727 }@@ -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 {@@ -242,7 +242,7 @@ func normalizeNonogramHintsForRender(hints [][]int, size int) [][]int {
242242 }243243 244244 normalized := make([][]int, size)245- for i := 0; i < size; i++ {245+ for i := range size {246246 if i >= len(hints) || len(hints[i]) == 0 {247247 normalized[i] = []int{0}248248 continue@@ -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+}@@ -515,7 +515,7 @@ func generateLinePossibilitiesRecursive(length int, hint []int, hintIdx, pos int
515515 newCurrent = append(newCurrent, cellEmpty)516516 }517517 518- for i := 0; i < blockLen; i++ {518+ for range blockLen {519519 newCurrent = append(newCurrent, cellFilled)520520 }521521 @@ -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 }@@ -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)@@ -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 {@@ -129,12 +129,7 @@ func GenerateSeededWithContext(ctx context.Context, mode NurikabeMode, rng *rand
129129 130130 area := mode.Width * mode.Height131131 clueTarget := int(math.Round(float64(area) * mode.ClueDensity))132- if clueTarget < 2 {133- clueTarget = 2134- }135- if clueTarget > area-2 {136- clueTarget = area - 2137- }132+ clueTarget = min(max(clueTarget, 2), area-2)138133 139134 profile := modeIslandProfile(mode)140135 budget := modeGenerationBudget(mode)@@ -224,7 +219,7 @@ outer:
224219 225220 roundSize := min(workerCount, budget.attempts-attemptID)226221 dispatched := 0227- for i := 0; i < roundSize; i++ {222+ for i := range roundSize {228223 seedA, seedB := deriveAttemptSeeds(masterA, masterB, attemptID+i)229224 job := attemptSeed{attemptID: attemptID + i, seedA: seedA, seedB: seedB}230225 select {@@ -1139,9 +1139,7 @@ func TestConstructedCandidateValidCompletedBoard(t *testing.T) {
11391139 mode := Modes[2].(NurikabeMode) // Medium11401140 profile := modeIslandProfile(mode)11411141 clueTarget := int(float64(mode.Width*mode.Height) * mode.ClueDensity)1142- if clueTarget < 2 {1143- clueTarget = 21144- }1142+ clueTarget = max(clueTarget, 2)11451143 11461144 var (11471145 candidate candidateResult@@ -1318,7 +1316,7 @@ func TestNoGoroutineLeakOnCancel(t *testing.T) {
13181316 mode := Modes[4].(NurikabeMode)13191317 before := runtime.NumGoroutine()13201318 1321- for i := 0; i < 8; i++ {1319+ for i := range 8 {13221320 ctx, cancel := context.WithCancel(context.Background())13231321 cancel()13241322 rng := rand.New(rand.NewPCG(uint64(400+i), uint64(900+i)))@@ -1336,7 +1334,7 @@ func TestNoGoroutineLeakOnDeadlineTimeout(t *testing.T) {
13361334 mode := Modes[4].(NurikabeMode)13371335 before := runtime.NumGoroutine()13381336 1339- for i := 0; i < 8; i++ {1337+ for i := range 8 {13401338 ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)13411339 rng := rand.New(rand.NewPCG(uint64(1400+i), uint64(1900+i)))13421340 _, _ = GenerateSeededWithContext(ctx, mode, rng)@@ -1407,7 +1405,6 @@ func BenchmarkCountSolutions(b *testing.B) {
14071405 }14081406 14091407 for _, tc := range cases {1410- tc := tc14111408 b.Run(tc.name, func(b *testing.B) {14121409 b.ReportAllocs()14131410 b.ResetTimer()@@ -4,6 +4,7 @@ import (
44 "context"55 "errors"66 "fmt"7+ "slices"78 )89 910 var errNodeLimit = errors.New("solver node limit exceeded")@@ -658,10 +659,8 @@ func maxReachableForComponentWithScratch(
658659 659660 func hasAnyUnknown(g grid) bool {660661 for _, row := range g {661- for _, c := range row {662- if c == unknownCell {663- return true664- }662+ if slices.Contains(row, unknownCell) {663+ return true665664 }666665 }667666 return false@@ -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@@ -253,7 +253,7 @@ func parseNonogramBody(bodyLines []string, path string, bodyStartLine int) (*Non
253253 254254 rowHints := make([][]int, height)255255 grid := make([][]string, height)256- for y := 0; y < height; y++ {256+ for y := range height {257257 row := dataRows[colHintRows+y]258258 hints := parseHintCells(row[:rowHintCols])259259 if len(hints) == 0 {@@ -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 @@ -67,7 +67,6 @@ var (
6767 func adaptLegacy(def game.Definition) Entry {6868 gameID := puzzle.CanonicalGameID(def.Name)6969 modes := make([]ModeEntry, 0, len(def.Modes))70- modeIDs := make([]puzzle.ModeID, 0, len(def.Modes))7170 for _, item := range def.Modes {7271 mode, ok := item.(game.Mode)7372 if !ok {@@ -93,7 +92,6 @@ func adaptLegacy(def game.Definition) Entry {
9392 Spawner: spawner,9493 Seeded: seeded,9594 })96- modeIDs = append(modeIDs, modeID)9795 }9896 9997 dailyIDs := make([]puzzle.ModeID, 0, len(def.DailyModes))@@ -40,7 +40,7 @@ func GeneratePuzzleSeeded(width, height, maxCage int, givenRatio float64, rng *r
4040 func generatePuzzleSeededWithProfile(width, height, maxCage int, givenRatio float64, profile generationProfile, rng *rand.Rand) (Puzzle, error) {4141 const maxAttempts = 244242 profile = profile.withDefaults(maxCage)43- for attempt := 0; attempt < maxAttempts; attempt++ {43+ for range maxAttempts {4444 cages := generateCages(width, height, maxCage, profile, rng)4545 geo, err := buildGeometry(width, height, cages)4646 if err != nil {@@ -206,7 +206,7 @@ func TestSampledGivenValueDistributionMatchesSolutions(t *testing.T) {
206206 totalSolutions := 0207207 totalGivens := 0208208 209- for sample := 0; sample < samples; sample++ {209+ for sample := range samples {210210 rng := rand.New(rand.NewPCG(uint64(3000+i*37+sample), uint64(4000+i*41+sample)))211211 puzzle, err := mode.generatePuzzleSeeded(rng)212212 if err != nil {@@ -2,6 +2,7 @@ package rippleeffect
22 33 import (44 "image/color"5+ "maps"56 "strconv"67 78 "charm.land/lipgloss/v2"@@ -175,12 +176,8 @@ func bridgeBackgrounds(m Model, completed map[int]color.Color) map[int]color.Col
175176 }176177 177178 backgrounds := activeCageBridgeBackgrounds(m)178- for cageIdx, bg := range completed {179- backgrounds[cageIdx] = bg180- }181- for cageIdx, bg := range conflictBridgeBackgrounds(m) {182- backgrounds[cageIdx] = bg183- }179+ maps.Copy(backgrounds, completed)180+ maps.Copy(backgrounds, conflictBridgeBackgrounds(m))184181 return backgrounds185182 }186183 @@ -37,8 +37,6 @@ func BenchmarkGeneratePuzzleBySize(b *testing.B) {
3737 skipBenchmarkInShortMode(b)3838 3939 for modeIndex, mode := range benchmarkShikakuModes() {40- mode := mode41- modeIndex := modeIndex4240 b.Run(mode.Title(), func(b *testing.B) {4341 b.ReportAllocs()4442 for i := 0; i < b.N; i++ {@@ -68,8 +66,6 @@ func BenchmarkPartitionGrid(b *testing.B) {
6866 skipBenchmarkInShortMode(b)6967 7068 for modeIndex, mode := range benchmarkShikakuModes() {71- mode := mode72- modeIndex := modeIndex7369 b.Run(mode.Title(), func(b *testing.B) {7470 b.ReportAllocs()7571 for i := 0; i < b.N; i++ {@@ -7,9 +7,6 @@ import (
77 88 func TestPartitionGridCoverageBySize(t *testing.T) {99 for modeIndex, mode := range benchmarkShikakuModes() {10- mode := mode11- modeIndex := modeIndex12-1310 t.Run(mode.Title(), func(t *testing.T) {1411 grid := makeUncoveredGenerationGrid(mode.Width, mode.Height)1512 partitions := make([]genRect, 0, mode.Width*mode.Height/2)@@ -78,9 +75,6 @@ func TestPartitionGridCoverageBySize(t *testing.T) {
7875 7976 func TestGeneratePuzzleClueInvariantsBySize(t *testing.T) {8077 for modeIndex, mode := range benchmarkShikakuModes() {81- mode := mode82- modeIndex := modeIndex83-8478 t.Run(mode.Title(), func(t *testing.T) {8579 puzzle, err := GeneratePuzzleSeeded(8680 mode.Width,@@ -30,7 +30,7 @@ func gridView(m Model, solved bool) string {
3030 return regionTokenAt(m, preview, x, y)3131 },3232 ZoneFill: func(zone int) color.Color {33- return regionBackground(m, zone, previewClue, previewValid)33+ return regionBackground(zone, previewClue, previewValid)3434 },3535 })3636 }@@ -61,7 +61,7 @@ func cellView(m Model, x, y int, preview *Rectangle, previewClue *Clue, previewV
6161 style = style.Bold(true)6262 }6363 64- if regionBG := regionBackground(m, token, previewClue, previewValid); regionBG != nil {64+ if regionBG := regionBackground(token, previewClue, previewValid); regionBG != nil {6565 bg = regionBG6666 fg = theme.TextOnBG(regionBG)6767 }@@ -128,7 +128,7 @@ func regionTokenAt(m Model, preview *Rectangle, x, y int) int {
128128 return unownedRegionID129129 }130130 131-func regionBackground(m Model, token int, previewClue *Clue, previewValid bool) color.Color {131+func regionBackground(token int, previewClue *Clue, previewValid bool) color.Color {132132 p := theme.Current()133133 switch token {134134 case previewRegionID:@@ -13,7 +13,6 @@ const (
1313 1414 const (1515 cardColumnGap = 216- cardRowGap = 21716 )1817 1918 func StaticHeight(cards []Card) int {@@ -23,9 +23,7 @@ func WeightsFromDefinitions(definitions []puzzle.Definition) Weights {
2323 count := len(def.Modes)2424 for i, mode := range def.Modes {2525 xp := int(math.Round(float64(i) / float64(count) * 10))26- if xp < 1 {27- xp = 128- }26+ xp = max(xp, 1)2927 weights[ModeKey{GameType: def.Name, Mode: mode.Title}] = xp3028 }3129 }@@ -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 @@ -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 }@@ -7,9 +7,6 @@ import (
77 88 func TestGenerateProvidedCellsUniquenessAcrossModes(t *testing.T) {99 for modeIndex, mode := range benchmarkSudokuModes() {10- mode := mode11- modeIndex := modeIndex12-1310 t.Run(mode.Title(), func(t *testing.T) {1411 rng := rand.New(rand.NewPCG(uint64(modeIndex+101), uint64(modeIndex+1001)))1512 cells := GenerateProvidedCellsSeeded(mode, rng)@@ -258,7 +258,7 @@ func GenerateProvidedCellsSeeded(mode SudokuRGBMode, rng *rand.Rand) []cell {
258258 bestCells := []cell{}259259 bestCount := gridSize*gridSize + 1260260 261- for attempt := 0; attempt < 96; attempt++ {261+ for range 96 {262262 g := newGrid(nil)263263 if !fillGridSeeded(&g, rng) {264264 continue@@ -56,8 +56,8 @@ func RenderTakuzuPDFBodyWithRules(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, ru
5656 5757 pdf.SetDrawColor(60, 60, 60)5858 pdf.SetLineWidth(pdfexport.ThinGridLineMM)59- for y := 0; y < size; y++ {60- for x := 0; x < size; x++ {59+ for y := range size {60+ for x := range size {6161 cellX := startX + float64(x)*cellSize6262 cellY := startY + float64(y)*cellSize6363 pdf.Rect(cellX, cellY, cellSize, cellSize, "D")@@ -75,8 +75,8 @@ func RenderTakuzuPDFBodyWithRules(pdf *fpdf.Fpdf, data *pdfexport.TakuzuData, ru
7575 }7676 }7777 78- for y := 0; y < size; y++ {79- for x := 0; x < size; x++ {78+ for y := range size {79+ for x := range size {8080 text := ""8181 if y < len(data.Givens) && x < len(data.Givens[y]) {8282 text = strings.TrimSpace(data.Givens[y][x])@@ -77,7 +77,7 @@ func generateCompleteSeeded(size int, rng *rand.Rand) grid {
7777 g := newGrid(createEmptyState(size))7878 7979 const maxRetries = 1080- for attempt := 0; attempt < maxRetries; attempt++ {80+ for range maxRetries {8181 for y := range size {8282 for x := range size {8383 g[y][x] = emptyCell@@ -434,11 +434,12 @@ func canPlaceWithStats(g grid, size, x, y int, val rune, stats *lineStats) bool
434434 }435435 436436 half := size / 2437- if val == zeroCell {437+ switch val {438+ case zeroCell:438439 if stats.rowZero[y] >= half || stats.colZero[x] >= half {439440 return false440441 }441- } else if val == oneCell {442+ case oneCell:442443 if stats.rowOne[y] >= half || stats.colOne[x] >= half {443444 return false444445 }@@ -410,15 +410,15 @@ func adjacentRelationCandidates(rels relations, x, y, size int) []relationCandid
410410 411411 func allUnusedRelationCandidates(rels relations, size int) []relationCandidate {412412 candidates := make([]relationCandidate, 0, 2*size*(size-1))413- for y := 0; y < size; y++ {414- for x := 0; x < size-1; x++ {413+ for y := range size {414+ for x := range size - 1 {415415 if rels.horizontal[y][x] == relationNone {416416 candidates = append(candidates, relationCandidate{horizontal: true, x: x, y: y})417417 }418418 }419419 }420- for y := 0; y < size-1; y++ {421- for x := 0; x < size; x++ {420+ for y := range size - 1 {421+ for x := range size {422422 if rels.vertical[y][x] == relationNone {423423 candidates = append(candidates, relationCandidate{horizontal: false, x: x, y: y})424424 }@@ -54,13 +54,13 @@ func newRelations(size int) relations {
5454 horizontal: make([][]rune, size),5555 vertical: make([][]rune, max(size-1, 0)),5656 }57- for y := 0; y < size; y++ {57+ for y := range size {5858 r.horizontal[y] = make([]rune, max(size-1, 0))5959 for x := range r.horizontal[y] {6060 r.horizontal[y][x] = relationNone6161 }6262 }63- for y := 0; y < size-1; y++ {63+ for y := range size - 1 {6464 r.vertical[y] = make([]rune, size)6565 for x := range r.vertical[y] {6666 r.vertical[y][x] = relationNone@@ -98,9 +98,9 @@ func deserializeRuneRows(s string, rows, cols int) [][]rune {
9898 9999 parsed := strings.Split(s, "\n")100100 out := make([][]rune, rows)101- for y := 0; y < rows; y++ {101+ for y := range rows {102102 out[y] = make([]rune, cols)103- for x := 0; x < cols; x++ {103+ for x := range cols {104104 out[y][x] = relationNone105105 }106106 if y >= len(parsed) {@@ -245,7 +245,7 @@ func TestSampledModeDistributionStaysNearProfile(t *testing.T) {
245245 totalHorizontal := 0246246 totalSame := 0247247 248- for sample := 0; sample < samples; sample++ {248+ for sample := range samples {249249 rng := rand.New(rand.NewPCG(uint64(1000+i*37+sample), uint64(2000+i*41+sample)))250250 complete := generateCompleteSeeded(mode.Size, rng)251251 _, provided, rels := generatePuzzleSeeded(complete, mode.Size, mode.Prefilled, mode.profile, rng)@@ -76,9 +76,7 @@ func renderStatsStatLine(label, value string) string {
7676 l := statsLabelStyle().Render(label)7777 v := statsValueStyle().Render(value)7878 gap := stats.CardInnerWidth - lipgloss.Width(l) - lipgloss.Width(v)79- if gap < 1 {80- gap = 181- }79+ gap = max(gap, 1)8280 return l + strings.Repeat(" ", gap) + v8381 }8482 @@ -96,12 +94,7 @@ func renderStatsXPBar(currentXP, level int) string {
9694 if xpNeeded > 0 {9795 filled = int(float64(xpIntoLevel) / float64(xpNeeded) * barWidth)9896 }99- if filled > barWidth {100- filled = barWidth101- }102- if filled < 0 {103- filled = 0104- }97+ filled = max(0, min(filled, barWidth))10598 10699 bar := statsXPBarFilledStyle().Render(strings.Repeat("\u2588", filled)) +107100 statsXPBarEmptyStyle().Render(strings.Repeat("\u2591", barWidth-filled))@@ -135,15 +128,11 @@ func RenderStatsBanner(b stats.ProfileBanner, width int) string {
135128 statsBannerValueStyle().Render(fmt.Sprintf("%d total (%2d/99 this week)", b.WeekliesCompleted, b.ThisWeekHighestIndex))136129 137130 gap := width - lipgloss.Width(col1) - lipgloss.Width(col2)138- if gap < 2 {139- gap = 2140- }131+ gap = max(gap, 2)141132 row1 := col1 + strings.Repeat(" ", gap) + col2142133 143134 gap2 := width - lipgloss.Width(col3) - lipgloss.Width(col4)144- if gap2 < 2 {145- gap2 = 2146- }135+ gap2 = max(gap2, 2)147136 row2 := col3 + strings.Repeat(" ", gap2) + col4148137 149138 bottomRule := statsBannerRuleStyle().Render(strings.Repeat("\u2500", max(width, 1)))@@ -134,9 +134,7 @@ func TablePagination(t table.Model) string {
134134 135135 // Estimate the visible window based on cursor and table height.136136 start := cursor - height + 1137- if start < 0 {138- start = 0139- }137+ start = max(start, 0)140138 end := start + height141139 if end > total {142140 end = total@@ -224,7 +224,7 @@ func alternatingCasePrefix(s string, n int) string {
224224 225225 func renderedWidth(s string) int {226226 maxW := 0227- for _, line := range strings.Split(s, "\n") {227+ for line := range strings.SplitSeq(s, "\n") {228228 if w := ansi.StringWidth(line); w > maxW {229229 maxW = w230230 }@@ -622,10 +622,3 @@ func squaredDistance(a, b game.Cursor) int {
622622 func cursorInBounds(cursor game.Cursor, maxX, maxY int) bool {623623 return cursor.X >= 0 && cursor.X <= maxX && cursor.Y >= 0 && cursor.Y <= maxY624624 }625-626-func min(a, b int) int {627- if a < b {628- return a629- }630- return b631-}