- commit
- b062913cc5ec0d2214a970dac623bbf57e37dd32
- parent
- efb6a02fe7cd8da1a8d6945444ce00fdd7f8e4ae
- author
- Dami <dami.etoile@gmail.com>
| M | fillomino/ | 26 | ++++ |
| M | fillomino/ | 47 | ++++-- |
| M | rippleeffect/ | 10 | +- |
| M | rippleeffect/ | 62 | +++++++-- |
| M | rippleeffect/ | 13 | +-- |
| M | shikaku/ | 76 | +++++++++++ |
| M | shikaku/ | 31 | +++-- |
| M | sudokurgb/ | 20 | +++ |
| M | sudokurgb/ | 3 | +- |
| M | takuzu/ | 10 | +- |
| M | takuzu/ | 7 | +- |
| M | takuzu/ | 187 | ++++++++++++++++++++++++--- |
| M | takuzu/ | 126 | ++++++++++++++++-- |
| M | takuzuplus/ | 10 | +- |
| M | takuzuplus/ | 4 | + |
| M | takuzuplus/ | 205 | ++++++++++++++++++++++++++---- |
| M | takuzuplus/ | 123 | ++++++++++++++++++ |
@@ -454,6 +454,32 @@ func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {
454454 }455455 }456456 457+func TestBuildRenderGridStateColorsIncompleteRegions(t *testing.T) {458+ base := grid{459+ {3, 3},460+ {0, 1},461+ }462+ m := Model{463+ width: 2,464+ height: 2,465+ grid: base,466+ provided: newProvidedMask(base),467+ conflicts: validateGridState(base).conflicts,468+ }469+470+ renderState := buildRenderGridState(m)471+ incompleteZone := renderState.zones[0][0]472+ if got := renderState.zones[0][1]; got != incompleteZone {473+ t.Fatalf("connected incomplete cells should share a zone: got %d and %d", incompleteZone, got)474+ }475+ if renderState.zoneFill[incompleteZone] == nil {476+ t.Fatal("expected incomplete zone to receive a background color")477+ }478+ if got := renderState.zoneFill[renderState.zones[1][0]]; got != nil {479+ t.Fatal("expected empty zone to remain transparent")480+ }481+}482+457483 func TestCursorRegionInfoViewNumberedCell(t *testing.T) {458484 m := Model{459485 width: 3,@@ -14,7 +14,7 @@ const cellWidth = game.DynamicGridCellWidth
1414 type renderGridState struct {1515 zones [][]int1616 activeZone int17- completed map[int]color.Color17+ zoneFill map[int]color.Color1818 }1919 2020 func cellBaseStyle() lipgloss.Style {@@ -34,7 +34,7 @@ func emptyCellStyle() lipgloss.Style {
3434 func cellView(3535 value int,3636 provided, cursor, rowHighlight, colHighlight, regionHighlight, solved, conflict bool,37- completedBG color.Color,37+ zoneBG color.Color,3838 ) string {3939 p := theme.Current()4040 style := cellBaseStyle()@@ -56,10 +56,10 @@ func cellView(
5656 style = style.Foreground(game.SolvedFG()).Background(p.SuccessBG)5757 } else if cursor {5858 style = game.CursorStyle()59- } else if completedBG != nil {60- style = style.Background(completedBG).Foreground(theme.TextOnBG(completedBG))6159 } else if regionHighlight {6260 style = style.Background(p.HighlightBG)61+ } else if zoneBG != nil {62+ style = style.Background(zoneBG).Foreground(theme.TextOnBG(zoneBG))6363 } else if rowHighlight || colHighlight {6464 style = style.Background(p.Surface)6565 }@@ -67,10 +67,10 @@ func cellView(
6767 if provided && value != 0 && !conflict && !solved && !cursor {6868 bg := p.BG6969 switch {70- case completedBG != nil:71- bg = completedBG7270 case regionHighlight:7371 bg = p.HighlightBG72+ case zoneBG != nil:73+ bg = zoneBG7474 case rowHighlight || colHighlight:7575 bg = p.Surface7676 }@@ -104,7 +104,7 @@ func gridView(m Model) string {
104104 Solved: m.solved,105105 Cell: func(x, y int) string {106106 zone := renderState.zones[y][x]107- completedBG := renderState.completed[zone]107+ zoneBG := renderState.zoneFill[zone]108108 return cellView(109109 m.grid[y][x],110110 m.provided[y][x],@@ -114,7 +114,7 @@ func gridView(m Model) string {
114114 renderState.activeZone >= 0 && zone == renderState.activeZone,115115 m.solved,116116 m.conflicts[y][x],117- completedBG,117+ zoneBG,118118 )119119 },120120 ZoneAt: func(x, y int) int {@@ -125,12 +125,10 @@ func gridView(m Model) string {
125125 switch {126126 case m.solved:127127 return p.SuccessBG128- case renderState.completed[zone] != nil:129- return renderState.completed[zone]130128 case renderState.activeZone >= 0 && zone == renderState.activeZone:131129 return p.HighlightBG132130 default:133- return nil131+ return renderState.zoneFill[zone]134132 }135133 },136134 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {@@ -153,10 +151,10 @@ func bridgeFill(m Model, renderState renderGridState, bridge game.DynamicGridBri
153151 154152 if bridge.Uniform {155153 switch {156- case renderState.completed[bridge.Zone] != nil:157- return nil158154 case renderState.activeZone >= 0 && bridge.Zone == renderState.activeZone:159155 return nil156+ case renderState.zoneFill[bridge.Zone] != nil:157+ return nil160158 }161159 }162160 @@ -255,7 +253,7 @@ func buildRenderGridState(m Model) renderGridState {
255253 256254 palette := theme.Current()257255 colors := palette.ThemeColors()258- completed := make(map[int]color.Color)256+ zoneFill := make(map[int]color.Color)259257 nextZone := 0260258 activeZone := -1261259 emptyZone := nextZone@@ -277,8 +275,11 @@ func buildRenderGridState(m Model) renderGridState {
277275 for _, cell := range comp.cells {278276 zones[cell.y][cell.x] = zone279277 }278+ if len(colors) > 0 {279+ zoneFill[zone] = incompleteRegionColor(comp, colors, palette.Surface)280+ }280281 if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {281- completed[zone] = completedRegionColor(comp, colors, palette.Surface)282+ zoneFill[zone] = completedRegionColor(comp, colors, palette.Surface)282283 }283284 }284285 }@@ -290,7 +291,7 @@ func buildRenderGridState(m Model) renderGridState {
290291 return renderGridState{291292 zones: zones,292293 activeZone: activeZone,293- completed: completed,294+ zoneFill: zoneFill,294295 }295296 }296297 @@ -304,9 +305,21 @@ func componentHasConflict(comp component, conflicts [][]bool) bool {
304305 }305306 306307 func completedRegionColor(comp component, colors []color.Color, base color.Color) color.Color {308+ return regionColor(comp, colors, base, 0.52)309+}310+311+func incompleteRegionColor(comp component, colors []color.Color, base color.Color) color.Color {312+ return regionColor(comp, colors, base, 0.26)313+}314+315+func regionColor(comp component, colors []color.Color, base color.Color, amount float64) color.Color {316+ if len(colors) == 0 {317+ return nil318+ }319+307320 anchor := comp.cells[0]308321 index := (anchor.y*37 + anchor.x*17 + comp.value*13) % len(colors)309- return theme.Blend(base, colors[index], 0.52)322+ return theme.Blend(base, colors[index], amount)310323 }311324 312325 func cursorRegionInfoStyle() lipgloss.Style {@@ -61,7 +61,7 @@ var Modes = []list.Item{
6161 "Compact cages with extra anchors for quick local reads.",6262 5,6363 3,64- 0.72,64+ 0.69,6565 generationProfile{6666 cageWeights: []int{0, 5, 6, 3},6767 frontierSamples: 3,@@ -74,7 +74,7 @@ var Modes = []list.Item{
7474 "Gentle spacing logic with compact cages and steady clues.",7575 6,7676 3,77- 0.67,77+ 0.64,7878 generationProfile{7979 cageWeights: []int{0, 3, 5, 4},8080 frontierSamples: 3,@@ -87,7 +87,7 @@ var Modes = []list.Item{
8787 "Mixed cage shapes with a balanced clue spread.",8888 7,8989 4,90- 0.62,90+ 0.59,9191 generationProfile{9292 cageWeights: []int{0, 2, 4, 5, 3},9393 frontierSamples: 2,@@ -100,7 +100,7 @@ var Modes = []list.Item{
100100 "Longer cages and lighter anchors create broader ripple scans.",101101 8,102102 4,103- 0.58,103+ 0.55,104104 generationProfile{105105 cageWeights: []int{0, 1, 2, 4, 5},106106 frontierSamples: 3,@@ -113,7 +113,7 @@ var Modes = []list.Item{
113113 "Sparser anchors and winding large cages push global deductions.",114114 9,115115 5,116- 0.54,116+ 0.51,117117 generationProfile{118118 cageWeights: []int{0, 1, 1, 3, 4, 5},119119 frontierSamples: 4,@@ -282,16 +282,16 @@ func TestSaveRoundTrip(t *testing.T) {
282282 }283283 284284 func TestChooseVisualKindPriority(t *testing.T) {285- if got := chooseVisualKind(true, false, true, true, true, true); got != visualConflictCursor {285+ if got := chooseVisualKind(true, false, true, true, true); got != visualConflictCursor {286286 t.Fatalf("conflicted cursor should win, got %v", got)287287 }288- if got := chooseVisualKind(true, true, false, true, true, true); got != visualSolved {288+ if got := chooseVisualKind(true, true, false, true, true); got != visualSolved {289289 t.Fatalf("solved should beat cursor, got %v", got)290290 }291- if got := chooseVisualKind(true, false, false, true, true, true); got != visualCursor {291+ if got := chooseVisualKind(true, false, false, true, true); got != visualCursor {292292 t.Fatalf("cursor should beat cage highlight, got %v", got)293293 }294- if got := chooseVisualKind(false, false, false, true, true, true); got != visualCompleted {294+ if got := chooseVisualKind(false, false, false, true, true); got != visualCompleted {295295 t.Fatalf("completed should beat transient cage highlight, got %v", got)296296 }297297 }@@ -362,6 +362,34 @@ func TestCellViewUsesGivenTintForImmutableGivens(t *testing.T) {
362362 }363363 }364364 365+func TestCellViewDoesNotHighlightCrosshairAxis(t *testing.T) {366+ geo, err := buildGeometry(3, 3, sampleCages())367+ if err != nil {368+ t.Fatal(err)369+ }370+371+ m := Model{372+ width: 3,373+ height: 3,374+ grid: newGrid(3, 3),375+ givens: newGrid(3, 3),376+ conflicts: newConflictGrid(3, 3),377+ cursor: pointCursor(0, 0),378+ geo: geo,379+ }380+381+ got := cellView(m, 0, 1, geo.cageGrid[m.cursor.Y][m.cursor.X], nil)382+ want := lipgloss.NewStyle().383+ Width(cellWidth).384+ AlignHorizontal(lipgloss.Center).385+ Foreground(theme.Current().FG).386+ Background(theme.Current().BG).387+ Render(" · ")388+ if got != want {389+ t.Fatalf("crosshair-axis cellView() = %q, want %q", got, want)390+ }391+}392+365393 func TestGridViewUsesUniformRowsForBronzeBirchState(t *testing.T) {366394 model, err := ImportModel([]byte(`{"width":5,"height":5,"state":". 2 1 . 3\n2 1 1 1 .\n1 1 3 . .\n1 . 1 3 2\n2 3 2 1 1","givens":". 2 1 . 3\n2 1 1 1 .\n1 1 3 . .\n1 . 1 3 2\n2 3 2 1 1","cages":[{"id":0,"size":3,"cells":[{"x":0,"y":0},{"x":1,"y":0},{"x":1,"y":1}]},{"id":1,"size":2,"cells":[{"x":2,"y":0},{"x":3,"y":0}]},{"id":2,"size":3,"cells":[{"x":4,"y":0},{"x":4,"y":1},{"x":4,"y":2}]},{"id":3,"size":2,"cells":[{"x":0,"y":1},{"x":0,"y":2}]},{"id":4,"size":1,"cells":[{"x":2,"y":1}]},{"id":5,"size":3,"cells":[{"x":3,"y":1},{"x":3,"y":2},{"x":3,"y":3}]},{"id":6,"size":3,"cells":[{"x":1,"y":2},{"x":1,"y":3},{"x":2,"y":2}]},{"id":7,"size":2,"cells":[{"x":0,"y":3},{"x":0,"y":4}]},{"id":8,"size":3,"cells":[{"x":2,"y":3},{"x":2,"y":4},{"x":1,"y":4}]},{"id":9,"size":2,"cells":[{"x":4,"y":3},{"x":4,"y":4}]},{"id":10,"size":1,"cells":[{"x":3,"y":4}]}],"mode_title":"bronze-birch"}`))367395 if err != nil {@@ -480,7 +508,7 @@ func TestBridgeFillDefersToUniformZoneBackground(t *testing.T) {
480508 }481509 }482510 483-func TestBridgeFillUsesOpenAnchoredCrosshair(t *testing.T) {511+func TestBridgeFillLeavesOpenAnchoredBridgeUnfilled(t *testing.T) {484512 geo, err := buildGeometry(3, 2, []Cage{485513 {ID: 0, Size: 2, Cells: []Cell{{0, 0}, {0, 1}}},486514 {ID: 1, Size: 2, Cells: []Cell{{1, 0}, {2, 0}}},@@ -514,8 +542,8 @@ func TestBridgeFillUsesOpenAnchoredCrosshair(t *testing.T) {
514542 {X: 2, Y: 0},515543 },516544 })517- if !sameColor(got, theme.Current().Surface) {518- t.Fatal("expected anchored open bridge on cursor row to use crosshair background")545+ if got != nil {546+ t.Fatal("expected anchored open bridge to remain unfilled")519547 }520548 }521549 @@ -557,7 +585,7 @@ func TestBridgeFillDoesNotExpandIntoOpenInterior(t *testing.T) {
557585 }558586 }559587 560-func TestBridgeFillVerticalUsesRowCrosshairOnly(t *testing.T) {588+func TestBridgeFillVerticalSeparatorStaysUnfilled(t *testing.T) {561589 m := Model{cursor: pointCursor(0, 1)}562590 563591 if got := bridgeFill(m, nil, game.DynamicGridBridge{@@ -565,11 +593,11 @@ func TestBridgeFillVerticalUsesRowCrosshairOnly(t *testing.T) {
565593 X: 1,566594 Y: 0,567595 }); got != nil {568- t.Fatal("expected vertical separator to ignore column-only crosshair match")596+ t.Fatal("expected vertical separator to remain unfilled")569597 }570598 }571599 572-func TestBridgeFillHorizontalUsesColumnCrosshairOnly(t *testing.T) {600+func TestBridgeFillHorizontalSeparatorStaysUnfilled(t *testing.T) {573601 m := Model{cursor: pointCursor(1, 0)}574602 575603 if got := bridgeFill(m, nil, game.DynamicGridBridge{@@ -577,11 +605,11 @@ func TestBridgeFillHorizontalUsesColumnCrosshairOnly(t *testing.T) {
577605 X: 0,578606 Y: 1,579607 }); got != nil {580- t.Fatal("expected horizontal separator to ignore row-only crosshair match")608+ t.Fatal("expected horizontal separator to remain unfilled")581609 }582610 }583611 584-func TestBridgeFillExtendsThroughClosedVerticalSeparator(t *testing.T) {612+func TestBridgeFillClosedVerticalSeparatorStaysUnfilled(t *testing.T) {585613 m := Model{cursor: pointCursor(0, 0)}586614 587615 got := bridgeFill(m, nil, game.DynamicGridBridge{@@ -589,12 +617,12 @@ func TestBridgeFillExtendsThroughClosedVerticalSeparator(t *testing.T) {
589617 X: 1,590618 Y: 0,591619 })592- if !sameColor(got, theme.Current().Surface) {593- t.Fatal("expected closed vertical separator on cursor row to use surface background")620+ if got != nil {621+ t.Fatal("expected closed vertical separator to remain unfilled")594622 }595623 }596624 597-func TestBridgeFillExtendsThroughClosedHorizontalSeparator(t *testing.T) {625+func TestBridgeFillClosedHorizontalSeparatorStaysUnfilled(t *testing.T) {598626 m := Model{cursor: pointCursor(0, 0)}599627 600628 got := bridgeFill(m, nil, game.DynamicGridBridge{@@ -602,8 +630,8 @@ func TestBridgeFillExtendsThroughClosedHorizontalSeparator(t *testing.T) {
602630 X: 0,603631 Y: 1,604632 })605- if !sameColor(got, theme.Current().Surface) {606- t.Fatal("expected closed horizontal separator on cursor column to use surface background")633+ if got != nil {634+ t.Fatal("expected closed horizontal separator to remain unfilled")607635 }608636 }609637 @@ -16,7 +16,6 @@ type visualKind int
1616 1717 const (1818 visualNormal visualKind = iota19- visualCrosshair2019 visualCage2120 visualCompleted2221 visualConflictCursor@@ -25,7 +24,7 @@ const (
2524 visualConflict2625 )2726 28-func chooseVisualKind(cursor, solved, conflict, completed, cageHighlight, crosshair bool) visualKind {27+func chooseVisualKind(cursor, solved, conflict, completed, cageHighlight bool) visualKind {2928 switch {3029 case cursor && conflict:3130 return visualConflictCursor@@ -39,8 +38,6 @@ func chooseVisualKind(cursor, solved, conflict, completed, cageHighlight, crossh
3938 return visualCompleted4039 case cageHighlight:4140 return visualCage42- case crosshair:43- return visualCrosshair4441 default:4542 return visualNormal4643 }@@ -77,7 +74,6 @@ func cellView(m Model, x, y, activeCage int, completedBG color.Color) string {
7774 m.conflicts[y][x],7875 completedBG != nil,7976 m.geo.cageGrid[y][x] == activeCage,80- y == m.cursor.Y || x == m.cursor.X,8177 )8278 8379 style := lipgloss.NewStyle().@@ -112,14 +108,12 @@ func cellView(m Model, x, y, activeCage int, completedBG color.Color) string {
112108 case visualCage:113109 bg = p.SelectionBG114110 fg = theme.TextOnBG(bg)115- case visualCrosshair:116- bg = p.Surface117111 }118112 119113 if m.givens[y][x] != 0 {120114 style = style.Bold(true)121115 switch kind {122- case visualNormal, visualCrosshair, visualCage, visualCompleted:116+ case visualNormal, visualCage, visualCompleted:123117 bg = theme.GivenTint(bg)124118 }125119 }@@ -195,9 +189,6 @@ func bridgeFill(m Model, bridgeBG map[int]color.Color, bridge game.DynamicGridBr
195189 if bridge.Count > 0 && !bridgeTouchesBorder(m.geo, bridge) {196190 return nil197191 }198- if game.DynamicGridBridgeOnCrosshairAxis(m.cursor, bridge) {199- return theme.Current().Surface200- }201192 return nil202193 }203194 @@ -9,6 +9,7 @@ import (
99 tea "charm.land/bubbletea/v2"1010 "charm.land/lipgloss/v2"1111 "github.com/FelineStateMachine/puzzletea/game"12+ "github.com/FelineStateMachine/puzzletea/theme"1213 "github.com/charmbracelet/x/ansi"1314 )1415 @@ -946,6 +947,81 @@ func TestGridViewUsesUniformRows(t *testing.T) {
946947 }947948 }948949 950+func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) {951+ m := Model{952+ puzzle: Puzzle{Width: 1, Height: 1},953+ cursor: game.Cursor{X: 0, Y: 0},954+ }955+956+ got := cellView(m, 0, 0, nil, nil, false, false)957+958+ p := theme.Current()959+ want := lipgloss.NewStyle().960+ Width(cellWidth).961+ AlignHorizontal(lipgloss.Center).962+ Foreground(p.TextDim).963+ Background(p.BG).964+ Render(game.CursorLeft + "·" + game.CursorRight)965+966+ if got != want {967+ t.Fatalf("cursor empty cell = %q, want %q", got, want)968+ }969+}970+971+func TestCellViewCursorPreservesSelectionColors(t *testing.T) {972+ selected := 0973+ m := Model{974+ puzzle: Puzzle{975+ Width: 1,976+ Height: 1,977+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 4}},978+ },979+ cursor: game.Cursor{X: 0, Y: 0},980+ selectedClue: &selected,981+ }982+983+ got := cellView(m, 0, 0, nil, nil, false, false)984+985+ bg := theme.Current().SelectionBG986+ want := lipgloss.NewStyle().987+ Width(cellWidth).988+ AlignHorizontal(lipgloss.Center).989+ Bold(true).990+ Foreground(theme.TextOnBG(bg)).991+ Background(bg).992+ Render(game.CursorLeft + "4" + game.CursorRight)993+994+ if got != want {995+ t.Fatalf("cursor selected clue = %q, want %q", got, want)996+ }997+}998+999+func TestCellViewCursorKeepsDoubleDigitClueVisible(t *testing.T) {1000+ m := Model{1001+ puzzle: Puzzle{1002+ Width: 1,1003+ Height: 1,1004+ Clues: []Clue{{ID: 0, X: 0, Y: 0, Value: 12}},1005+ },1006+ cursor: game.Cursor{X: 0, Y: 0},1007+ }1008+1009+ got := cellView(m, 0, 0, nil, nil, false, false)1010+1011+ p := theme.Current()1012+ want := lipgloss.NewStyle().1013+ Width(cellWidth).1014+ AlignHorizontal(lipgloss.Center).1015+ Bold(true).1016+ Foreground(p.Given).1017+ Background(p.BG).1018+ Render(game.CursorLeft + "12")1019+1020+ if got != want {1021+ t.Fatalf("cursor double-digit clue = %q, want %q", got, want)1022+ }1023+}1024+9491025 func TestHelpToggleInvalidatesOriginCache(t *testing.T) {9501026 m := Model{9511027 puzzle: *simplePuzzle(),@@ -77,24 +77,31 @@ func cellView(m Model, x, y int, preview *Rectangle, previewClue *Clue, previewV
7777 fg = p.SolvedFG7878 }7979 80- if solved && isCursor {81- style = game.CursorSolvedStyle()82- if clue != nil {83- style = style.Bold(true)84- }85- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)86- }8780 if isCursor {88- style = game.CursorStyle()89- if clue != nil {90- style = style.Bold(true)91- }92- return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(display)81+ display = cursorDisplay(display)9382 }9483 9584 return style.Foreground(fg).Background(bg).Render(display)9685 }9786 87+func cursorDisplay(display string) string {88+ runes := []rune(display)89+ if len(runes) != cellWidth {90+ return display91+ }92+93+ switch {94+ case runes[0] == ' ' && runes[cellWidth-1] == ' ':95+ return game.CursorLeft + string(runes[1]) + game.CursorRight96+ case runes[cellWidth-1] == ' ':97+ return game.CursorLeft + string(runes[:cellWidth-1])98+ case runes[0] == ' ':99+ return string(runes[1:]) + game.CursorRight100+ default:101+ return display102+ }103+}104+98105 func activePreview(m Model) (*Rectangle, *Clue, bool) {99106 var preview *Rectangle100107 var previewClue *Clue@@ -228,3 +228,23 @@ func TestCellViewUsesErrorBackgroundForBoxConflictOnly(t *testing.T) {
228228 t.Fatalf("row-only conflict cellView() = %q, want %q", got, want)229229 }230230 }231+232+func TestUserCellViewUsesDefaultBoardBackground(t *testing.T) {233+ g := newGrid(nil)234+ g[0][0].v = 2235+ m := Model{236+ grid: g,237+ analysis: analyzeGrid(g),238+ keys: DefaultKeyMap,239+ cursor: game.Cursor{X: 8, Y: 8},240+ }241+242+ got := cellView(m, 0, 0, false)243+ want := userCellStyle(2).244+ Width(cellWidth).245+ AlignHorizontal(lipgloss.Center).246+ Render("■")247+ if got != want {248+ t.Fatalf("user cellView() = %q, want %q", got, want)249+ }250+}@@ -32,7 +32,8 @@ func providedCellStyle(value int, bg color.Color) lipgloss.Style {
3232 3333 func userCellStyle(value int) lipgloss.Style {3434 return lipgloss.NewStyle().35- Foreground(symbolColor(value))35+ Foreground(symbolColor(value)).36+ Background(game.DefaultBorderColors().BackgroundBG)3637 }3738 3839 func boxConflictCellStyle() lipgloss.Style {@@ -83,8 +83,14 @@ func (m Model) View() string {
8383 if m.solved {8484 return game.ComposeGameView(title, grid)8585 }86+ info := countContextView(m)8687 status := statusBarView(m.showFullHelp)87- return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))88+ return game.ComposeGameViewRows(89+ title,90+ grid,91+ game.StaticRow(info),92+ game.StableRow(status, statusBarView(false), statusBarView(true)),93+ )8894 }8995 9096 func (m Model) SetTitle(t string) game.Gamer {@@ -136,6 +142,8 @@ func (m Model) GetDebugInfo() string {
136142 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},137143 {"Grid Size", fmt.Sprintf("%d×%d", m.size, m.size)},138144 {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.size*m.size)},145+ {"Row Counts", countPairString(countLine(m.grid[m.cursor.Y]))},146+ {"Col Counts", countPairString(countColumn(m.grid, m.cursor.X, m.size))},139147 })140148 }141149 @@ -11,6 +11,10 @@ Fill the grid with two symbols so that every row and column obeys three simple r
1111 Pre-filled cells (shown in **bold**) are locked and cannot be changed. The puzzle is1212 solved when every cell is filled and all three rules are satisfied.1313 14+The context row below the grid shows the current cursor row and column counts for15+`0` and `1`. Count chips invert when a line reaches its quota and turn into an16+error chip if a line goes over quota.17+1418 ## Controls1519 1620 | Key | Action |@@ -30,7 +34,8 @@ solved when every cell is filled and all three rules are satisfied.
3034 either side *must* be the opposite symbol. For example, ● ● means the neighbours3135 are both ○.3236 - **Count what you have**: When a row or column already has its full quota of one33- symbol, every remaining empty cell in that line must be the other symbol.37+ symbol, the context row marks that count so every remaining empty cell in that38+ line must be the other symbol.3439 - **Avoid the third**: Before placing a symbol, glance one and two cells ahead in3540 each direction. If placing it would create three in a row, it has to be the3641 other symbol.@@ -2,6 +2,8 @@ package takuzu
22 33 import (44 "image/color"5+ "strconv"6+ "strings"57 68 "charm.land/lipgloss/v2"79 "github.com/FelineStateMachine/puzzletea/game"@@ -39,6 +41,17 @@ func renderStyleMap() map[rune]lipgloss.Style {
3941 }4042 }4143 44+type countPair struct {45+ zeros int46+ ones int47+}48+49+type countContext struct {50+ row countPair51+ col countPair52+ target int53+}54+4255 func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string {4356 p := theme.Current()4457 styles := renderStyleMap()@@ -53,29 +66,38 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
5366 }5467 5568 switch {56- case isCursor && solved:57- style = game.CursorSolvedStyle()58- text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight59- case isCursor:60- style = game.CursorStyle()61- text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight6269 case solved:6370 style = style.Foreground(p.SolvedFG).Background(p.SuccessBG)64- case inCursorRow || inCursorCol:65- style = style.Background(p.Surface)6671 }6772 68- if isProvided && val != emptyCell && !isCursor && !solved {69- bg := p.BG70- if inCursorRow || inCursorCol {71- bg = p.Surface72- }73- style = style.Bold(true).Background(theme.GivenTint(bg))73+ if isProvided && val != emptyCell && !solved {74+ style = style.Bold(true).Background(theme.GivenTint(p.BG))75+ }76+ if isCursor {77+ text = cursorText(text)7478 }7579 7680 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)7781 }7882 83+func cursorText(text string) string {84+ runes := []rune(text)85+ if len(runes) != cellWidth {86+ return text87+ }88+89+ switch {90+ case runes[0] == ' ' && runes[cellWidth-1] == ' ':91+ return game.CursorLeft + string(runes[1]) + game.CursorRight92+ case runes[cellWidth-1] == ' ':93+ return game.CursorLeft + string(runes[:cellWidth-1])94+ case runes[0] == ' ':95+ return string(runes[1:]) + game.CursorRight96+ default:97+ return text98+ }99+}100+79101 func gridView(m Model) string {80102 return game.RenderDynamicGrid(game.DynamicGridSpec{81103 Width: m.size,@@ -100,19 +122,140 @@ func gridView(m Model) string {
100122 })101123 }102124 103-func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {104- if m.solved {105- return nil125+func bridgeFill(_ Model, _ game.DynamicGridBridge) color.Color {126+ return nil127+}128+129+func countContextView(m Model) string {130+ ctx := buildCountContext(m.grid, m.cursor, m.size)131+ width := countValueWidth(ctx.target)132+ label := lipgloss.NewStyle().Foreground(theme.Current().Info)133+134+ var b strings.Builder135+ b.WriteString(label.Render("row "))136+ b.WriteString(countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":"))137+ b.WriteString(renderCountValue(ctx.row.zeros, ctx.target, zeroCell, width))138+ b.WriteString(label.Render(" "))139+ b.WriteString(countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":"))140+ b.WriteString(renderCountValue(ctx.row.ones, ctx.target, oneCell, width))141+ b.WriteString(label.Render(" col "))142+ b.WriteString(countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":"))143+ b.WriteString(renderCountValue(ctx.col.zeros, ctx.target, zeroCell, width))144+ b.WriteString(label.Render(" "))145+ b.WriteString(countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":"))146+ b.WriteString(renderCountValue(ctx.col.ones, ctx.target, oneCell, width))147+ return b.String()148+}149+150+func buildCountContext(g grid, cursor game.Cursor, size int) countContext {151+ if size <= 0 || len(g) == 0 {152+ return countContext{}106153 }107- if game.DynamicGridBridgeOnCrosshairAxis(m.cursor, bridge) {108- return theme.Current().Surface154+ row := 0155+ if cursor.Y >= 0 && cursor.Y < len(g) {156+ row = cursor.Y157+ }158+ col := 0159+ if cursor.X >= 0 && len(g[row]) > 0 && cursor.X < len(g[row]) {160+ col = cursor.X161+ }162+ return countContext{163+ row: countLine(g[row]),164+ col: countColumn(g, col, size),165+ target: size / 2,166+ }167+}168+169+func countLine(row []rune) countPair {170+ var counts countPair171+ for _, value := range row {172+ switch value {173+ case zeroCell:174+ counts.zeros++175+ case oneCell:176+ counts.ones++177+ }178+ }179+ return counts180+}181+182+func countColumn(g grid, col, size int) countPair {183+ var counts countPair184+ for y := 0; y < size && y < len(g); y++ {185+ if col < 0 || col >= len(g[y]) {186+ continue187+ }188+ switch g[y][col] {189+ case zeroCell:190+ counts.zeros++191+ case oneCell:192+ counts.ones++193+ }194+ }195+ return counts196+}197+198+func countPairString(counts countPair) string {199+ return strconv.Itoa(counts.zeros) + "/" + strconv.Itoa(counts.ones)200+}201+202+func renderCountValue(count, target int, value rune, width int) string {203+ text := strconv.Itoa(count) + "/" + strconv.Itoa(target)204+ style := countValueStyle(value)205+ if count == target {206+ style = metGoalCountStyle(value)207+ }208+ if count > target {209+ style = overGoalCountStyle()210+ }211+ return style.Width(width).AlignHorizontal(lipgloss.Right).Render(text)212+}213+214+func countValueWidth(target int) int {215+ return len(strconv.Itoa(target))*2 + 1216+}217+218+func countValueStyle(value rune) lipgloss.Style {219+ return lipgloss.NewStyle().Foreground(binaryValueColor(value))220+}221+222+func countLabelStyle(value rune) lipgloss.Style {223+ return lipgloss.NewStyle().224+ Bold(true).225+ Foreground(binaryValueColor(value))226+}227+228+func metGoalCountStyle(value rune) lipgloss.Style {229+ bg := binaryValueColor(value)230+ return lipgloss.NewStyle().231+ Bold(true).232+ Foreground(theme.TextOnBG(bg)).233+ Background(bg)234+}235+236+func overGoalCountStyle() lipgloss.Style {237+ bg := game.ConflictBG()238+ return lipgloss.NewStyle().239+ Bold(true).240+ Foreground(theme.TextOnBG(bg)).241+ Background(bg)242+}243+244+func binaryValueColor(value rune) color.Color {245+ p := theme.Current()246+ switch value {247+ case zeroCell:248+ return p.Accent249+ case oneCell:250+ return p.Secondary251+ default:252+ return p.TextDim109253 }110- return nil111254 }112255 113256 func statusBarView(showFullHelp bool) string {114257 if showFullHelp {115- return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")258+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z/0: ● x/1: ○ bkspc: clear esc: menu ctrl+r: reset ctrl+h: help")116259 }117- return game.StatusBarStyle().Render("mouse: click/cycle z: ● x: ○")260+ return game.StatusBarStyle().Render("mouse: click/cycle z/0: ● x/1: ○")118261 }@@ -3,8 +3,8 @@ package takuzu
33 import (44 "encoding/json"55 "image"6- "image/color"76 "math/rand/v2"7+ "strings"88 "testing"99 1010 tea "charm.land/bubbletea/v2"@@ -38,15 +38,6 @@ func validGrid6() grid {
3838 )3939 }4040 41-func sameColor(left, right color.Color) bool {42- if left == nil || right == nil {43- return left == nil && right == nil44- }45- lr, lg, lb, la := left.RGBA()46- rr, rg, rb, ra := right.RGBA()47- return lr == rr && lg == rg && lb == rb && la == ra48-}49-5041 // --- canPlace (P0) ---5142 5243 func TestCanPlace_Horizontal_Triples(t *testing.T) {@@ -1039,7 +1030,110 @@ func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) {
10391030 }10401031 }10411032 1042-func TestBridgeFillUsesVerticalCrosshairAcrossOpenInterior(t *testing.T) {1033+func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) {1034+ p := theme.Current()1035+1036+ got := cellView(emptyCell, false, true, false, false, false)1037+ want := lipgloss.NewStyle().1038+ Foreground(p.TextDim).1039+ Background(p.BG).1040+ Width(cellWidth).1041+ AlignHorizontal(lipgloss.Center).1042+ Render(game.CursorLeft + "·" + game.CursorRight)1043+1044+ if got != want {1045+ t.Fatalf("cursor empty cellView() = %q, want %q", got, want)1046+ }1047+}1048+1049+func TestCellViewCursorPreservesProvidedCellColors(t *testing.T) {1050+ p := theme.Current()1051+1052+ got := cellView(zeroCell, true, true, true, false, false)1053+ want := lipgloss.NewStyle().1054+ Bold(true).1055+ Foreground(p.Accent).1056+ Background(theme.GivenTint(p.BG)).1057+ Width(cellWidth).1058+ AlignHorizontal(lipgloss.Center).1059+ Render(game.CursorLeft + "●" + game.CursorRight)1060+1061+ if got != want {1062+ t.Fatalf("cursor provided zero cellView() = %q, want %q", got, want)1063+ }1064+}1065+1066+func TestKeyBindingsUseZeroAndOneForPlacement(t *testing.T) {1067+ m := testMouseModel()1068+ m.cursor = game.Cursor{X: 1, Y: 1}1069+1070+ next, _ := m.Update(tea.KeyPressMsg{Code: '0', Text: "0"})1071+ got := next.(Model)1072+ if got.grid[1][1] != zeroCell {1073+ t.Fatalf("key 0 cell = %q, want %q", got.grid[1][1], zeroCell)1074+ }1075+1076+ next, _ = got.Update(tea.KeyPressMsg{Code: '1', Text: "1"})1077+ got = next.(Model)1078+ if got.grid[1][1] != oneCell {1079+ t.Fatalf("key 1 cell = %q, want %q", got.grid[1][1], oneCell)1080+ }1081+}1082+1083+func TestBuildCountContextUsesCursorRowAndColumn(t *testing.T) {1084+ m := testMouseModel()1085+ m.cursor = game.Cursor{X: 3, Y: 0}1086+1087+ ctx := buildCountContext(m.grid, m.cursor, m.size)1088+ if got, want := ctx.row, (countPair{zeros: 1, ones: 1}); got != want {1089+ t.Fatalf("row counts = %+v, want %+v", got, want)1090+ }1091+ if got, want := ctx.col, (countPair{zeros: 1, ones: 1}); got != want {1092+ t.Fatalf("col counts = %+v, want %+v", got, want)1093+ }1094+ if got, want := ctx.target, 2; got != want {1095+ t.Fatalf("target = %d, want %d", got, want)1096+ }1097+}1098+1099+func TestCountContextViewHighlightsMetAndOverGoals(t *testing.T) {1100+ m := Model{1101+ size: 4,1102+ grid: grid{1103+ []rune("0011"),1104+ []rune("000."),1105+ []rune("...."),1106+ []rune("...."),1107+ },1108+ cursor: game.Cursor{X: 0, Y: 1},1109+ }1110+1111+ view := countContextView(m)1112+ wantMet := metGoalCountStyle(zeroCell).Width(3).AlignHorizontal(lipgloss.Right).Render("2/2")1113+ if !strings.Contains(view, wantMet) {1114+ t.Fatalf("countContextView() = %q, want met-goal chip %q", view, wantMet)1115+ }1116+ wantOver := overGoalCountStyle().Width(3).AlignHorizontal(lipgloss.Right).Render("3/2")1117+ if !strings.Contains(view, wantOver) {1118+ t.Fatalf("countContextView() = %q, want over-goal chip %q", view, wantOver)1119+ }1120+ if !strings.Contains(view, string([]rune(renderRuneMap[zeroCell])[1])+":") {1121+ t.Fatalf("countContextView() = %q, want zero symbol label", view)1122+ }1123+ if !strings.Contains(view, string([]rune(renderRuneMap[oneCell])[1])+":") {1124+ t.Fatalf("countContextView() = %q, want one symbol label", view)1125+ }1126+ wantZeroLabel := countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":")1127+ if !strings.Contains(view, wantZeroLabel) {1128+ t.Fatalf("countContextView() = %q, want colored zero label %q", view, wantZeroLabel)1129+ }1130+ wantOneLabel := countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":")1131+ if !strings.Contains(view, wantOneLabel) {1132+ t.Fatalf("countContextView() = %q, want colored one label %q", view, wantOneLabel)1133+ }1134+}1135+1136+func TestBridgeFillLeavesVerticalOpenInteriorUnfilled(t *testing.T) {10431137 m := testMouseModel()10441138 m.cursor = game.Cursor{X: 0, Y: 1}10451139 @@ -1054,12 +1148,12 @@ func TestBridgeFillUsesVerticalCrosshairAcrossOpenInterior(t *testing.T) {
10541148 {X: 1, Y: 1},10551149 },10561150 })1057- if !sameColor(got, theme.Current().Surface) {1058- t.Fatal("expected open vertical bridge on cursor row to use crosshair background")1151+ if got != nil {1152+ t.Fatal("expected open vertical bridge on cursor row to remain unfilled")10591153 }10601154 }10611155 1062-func TestBridgeFillUsesHorizontalCrosshairAcrossOpenInterior(t *testing.T) {1156+func TestBridgeFillLeavesHorizontalOpenInteriorUnfilled(t *testing.T) {10631157 m := testMouseModel()10641158 m.cursor = game.Cursor{X: 0, Y: 1}10651159 @@ -1074,8 +1168,8 @@ func TestBridgeFillUsesHorizontalCrosshairAcrossOpenInterior(t *testing.T) {
10741168 {X: 0, Y: 1},10751169 },10761170 })1077- if !sameColor(got, theme.Current().Surface) {1078- t.Fatal("expected open horizontal bridge on cursor column to use crosshair background")1171+ if got != nil {1172+ t.Fatal("expected open horizontal bridge on cursor column to remain unfilled")10791173 }10801174 }10811175 @@ -84,8 +84,14 @@ func (m Model) View() string {
8484 if m.solved {8585 return game.ComposeGameView(title, grid)8686 }87+ info := countContextView(m)8788 status := statusBarView(m.showFullHelp)88- return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))89+ return game.ComposeGameViewRows(90+ title,91+ grid,92+ game.StaticRow(info),93+ game.StableRow(status, statusBarView(false), statusBarView(true)),94+ )8995 }9096 9197 func (m Model) SetTitle(t string) game.Gamer {@@ -138,6 +144,8 @@ func (m Model) GetDebugInfo() string {
138144 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},139145 {"Grid Size", fmt.Sprintf("%d×%d", m.size, m.size)},140146 {"Cells Filled", fmt.Sprintf("%d / %d", filled, m.size*m.size)},147+ {"Row Counts", countPairString(countLine(m.grid[m.cursor.Y]))},148+ {"Col Counts", countPairString(countColumn(m.grid, m.cursor.X, m.size))},141149 {"Relation Clues", fmt.Sprintf("%d", countRelations(m.relations))},142150 })143151 }@@ -11,6 +11,10 @@ Fill the grid with two symbols so that the normal Takuzu rules hold, while also
1111 1212 Pre-filled cells are locked. Relation clues are also fixed and cannot be edited.1313 14+The context row below the grid shows the current cursor row and column counts for15+`0` and `1`. Count chips invert when a line reaches its quota and turn into an16+error chip if a line goes over quota.17+1418 ## Controls1519 1620 | Key | Action |@@ -2,6 +2,8 @@ package takuzuplus
22 33 import (44 "image/color"5+ "strconv"6+ "strings"57 68 "charm.land/lipgloss/v2"79 "github.com/FelineStateMachine/puzzletea/game"@@ -39,6 +41,17 @@ func renderStyleMap() map[rune]lipgloss.Style {
3941 }4042 }4143 44+type countPair struct {45+ zeros int46+ ones int47+}48+49+type countContext struct {50+ row countPair51+ col countPair52+ target int53+}54+4255 func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved bool) string {4356 p := theme.Current()4457 styles := renderStyleMap()@@ -53,29 +66,38 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
5366 }5467 5568 switch {56- case isCursor && solved:57- style = game.CursorSolvedStyle()58- text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight59- case isCursor:60- style = game.CursorStyle()61- text = game.CursorLeft + string([]rune(text)[1]) + game.CursorRight6269 case solved:6370 style = style.Foreground(p.SolvedFG).Background(p.SuccessBG)64- case inCursorRow || inCursorCol:65- style = style.Background(p.Surface)6671 }6772 68- if isProvided && val != emptyCell && !isCursor && !solved {69- bg := p.BG70- if inCursorRow || inCursorCol {71- bg = p.Surface72- }73- style = style.Bold(true).Background(theme.GivenTint(bg))73+ if isProvided && val != emptyCell && !solved {74+ style = style.Bold(true).Background(theme.GivenTint(p.BG))75+ }76+ if isCursor {77+ text = cursorText(text)7478 }7579 7680 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)7781 }7882 83+func cursorText(text string) string {84+ runes := []rune(text)85+ if len(runes) != cellWidth {86+ return text87+ }88+89+ switch {90+ case runes[0] == ' ' && runes[cellWidth-1] == ' ':91+ return game.CursorLeft + string(runes[1]) + game.CursorRight92+ case runes[cellWidth-1] == ' ':93+ return game.CursorLeft + string(runes[:cellWidth-1])94+ case runes[0] == ' ':95+ return string(runes[1:]) + game.CursorRight96+ default:97+ return text98+ }99+}100+79101 func gridView(m Model) string {80102 return game.RenderDynamicGrid(game.DynamicGridSpec{81103 Width: m.size,@@ -95,19 +117,7 @@ func gridView(m Model) string {
95117 return 096118 },97119 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {98- if m.solved {99- return theme.Current().SuccessBG100- }101- if bg := relationBridgeBackground(m, bridge); bg != nil {102- return bg103- }104- for i := 0; i < bridge.Count; i++ {105- cell := bridge.Cells[i]106- if cell.X == m.cursor.X || cell.Y == m.cursor.Y {107- return theme.Current().Surface108- }109- }110- return nil120+ return bridgeFill(m, bridge)111121 },112122 VerticalBridgeText: func(x, y int) string {113123 if x <= 0 || x >= m.size {@@ -132,6 +142,16 @@ func gridView(m Model) string {
132142 })133143 }134144 145+func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {146+ if m.solved {147+ return theme.Current().SuccessBG148+ }149+ if bg := relationBridgeBackground(m, bridge); bg != nil {150+ return bg151+ }152+ return nil153+}154+135155 func relationBridgeBackground(m Model, bridge game.DynamicGridBridge) color.Color {136156 switch bridge.Kind {137157 case game.DynamicGridBridgeVertical:@@ -170,9 +190,136 @@ func relationStateBackground(state int) color.Color {
170190 }171191 }172192 193+func countContextView(m Model) string {194+ ctx := buildCountContext(m.grid, m.cursor, m.size)195+ width := countValueWidth(ctx.target)196+ label := lipgloss.NewStyle().Foreground(theme.Current().Info)197+198+ var b strings.Builder199+ b.WriteString(label.Render("row "))200+ b.WriteString(countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":"))201+ b.WriteString(renderCountValue(ctx.row.zeros, ctx.target, zeroCell, width))202+ b.WriteString(label.Render(" "))203+ b.WriteString(countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":"))204+ b.WriteString(renderCountValue(ctx.row.ones, ctx.target, oneCell, width))205+ b.WriteString(label.Render(" col "))206+ b.WriteString(countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":"))207+ b.WriteString(renderCountValue(ctx.col.zeros, ctx.target, zeroCell, width))208+ b.WriteString(label.Render(" "))209+ b.WriteString(countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":"))210+ b.WriteString(renderCountValue(ctx.col.ones, ctx.target, oneCell, width))211+ return b.String()212+}213+214+func buildCountContext(g grid, cursor game.Cursor, size int) countContext {215+ if size <= 0 || len(g) == 0 {216+ return countContext{}217+ }218+ row := 0219+ if cursor.Y >= 0 && cursor.Y < len(g) {220+ row = cursor.Y221+ }222+ col := 0223+ if cursor.X >= 0 && len(g[row]) > 0 && cursor.X < len(g[row]) {224+ col = cursor.X225+ }226+ return countContext{227+ row: countLine(g[row]),228+ col: countColumn(g, col, size),229+ target: size / 2,230+ }231+}232+233+func countLine(row []rune) countPair {234+ var counts countPair235+ for _, value := range row {236+ switch value {237+ case zeroCell:238+ counts.zeros++239+ case oneCell:240+ counts.ones++241+ }242+ }243+ return counts244+}245+246+func countColumn(g grid, col, size int) countPair {247+ var counts countPair248+ for y := 0; y < size && y < len(g); y++ {249+ if col < 0 || col >= len(g[y]) {250+ continue251+ }252+ switch g[y][col] {253+ case zeroCell:254+ counts.zeros++255+ case oneCell:256+ counts.ones++257+ }258+ }259+ return counts260+}261+262+func countPairString(counts countPair) string {263+ return strconv.Itoa(counts.zeros) + "/" + strconv.Itoa(counts.ones)264+}265+266+func renderCountValue(count, target int, value rune, width int) string {267+ text := strconv.Itoa(count) + "/" + strconv.Itoa(target)268+ style := countValueStyle(value)269+ if count == target {270+ style = metGoalCountStyle(value)271+ }272+ if count > target {273+ style = overGoalCountStyle()274+ }275+ return style.Width(width).AlignHorizontal(lipgloss.Right).Render(text)276+}277+278+func countValueWidth(target int) int {279+ return len(strconv.Itoa(target))*2 + 1280+}281+282+func countValueStyle(value rune) lipgloss.Style {283+ return lipgloss.NewStyle().Foreground(binaryValueColor(value))284+}285+286+func countLabelStyle(value rune) lipgloss.Style {287+ return lipgloss.NewStyle().288+ Bold(true).289+ Foreground(binaryValueColor(value))290+}291+292+func metGoalCountStyle(value rune) lipgloss.Style {293+ bg := binaryValueColor(value)294+ return lipgloss.NewStyle().295+ Bold(true).296+ Foreground(theme.TextOnBG(bg)).297+ Background(bg)298+}299+300+func overGoalCountStyle() lipgloss.Style {301+ bg := game.ConflictBG()302+ return lipgloss.NewStyle().303+ Bold(true).304+ Foreground(theme.TextOnBG(bg)).305+ Background(bg)306+}307+308+func binaryValueColor(value rune) color.Color {309+ p := theme.Current()310+ switch value {311+ case zeroCell:312+ return p.Accent313+ case oneCell:314+ return p.Secondary315+ default:316+ return p.TextDim317+ }318+}319+173320 func statusBarView(showFullHelp bool) string {174321 if showFullHelp {175- return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z: ● x: ○ bkspc: clear =: same clue x: opposite clue esc: menu ctrl+r: reset ctrl+h: help")322+ return game.StatusBarStyle().Render("arrows/wasd: move mouse: click/cycle z/0: ● x/1: ○ bkspc: clear =: same clue x: opposite clue esc: menu ctrl+r: reset ctrl+h: help")176323 }177- return game.StatusBarStyle().Render("mouse: click/cycle z: ● x: ○ fixed clues: = and x")324+ return game.StatusBarStyle().Render("mouse: click/cycle z/0: ● x/1: ○ fixed clues: = and x")178325 }@@ -3,6 +3,7 @@ package takuzuplus
33 import (44 "encoding/json"55 "fmt"6+ "image"67 "image/color"78 "math/rand/v2"89 "strings"@@ -518,6 +519,128 @@ func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) {
518519 }519520 }520521 522+func TestCellViewCursorUsesGlyphsWithoutChangingEmptyCellColors(t *testing.T) {523+ p := theme.Current()524+525+ got := cellView(emptyCell, false, true, false, false, false)526+ want := lipgloss.NewStyle().527+ Foreground(p.TextDim).528+ Background(p.BG).529+ Width(cellWidth).530+ AlignHorizontal(lipgloss.Center).531+ Render(game.CursorLeft + "·" + game.CursorRight)532+533+ if got != want {534+ t.Fatalf("cursor empty cellView() = %q, want %q", got, want)535+ }536+}537+538+func TestCellViewCursorPreservesProvidedCellColors(t *testing.T) {539+ p := theme.Current()540+541+ got := cellView(zeroCell, true, true, true, false, false)542+ want := lipgloss.NewStyle().543+ Bold(true).544+ Foreground(p.Accent).545+ Background(theme.GivenTint(p.BG)).546+ Width(cellWidth).547+ AlignHorizontal(lipgloss.Center).548+ Render(game.CursorLeft + "●" + game.CursorRight)549+550+ if got != want {551+ t.Fatalf("cursor provided zero cellView() = %q, want %q", got, want)552+ }553+}554+555+func TestKeyBindingsUseZeroAndOneForPlacement(t *testing.T) {556+ m := testMouseModel()557+ m.cursor = game.Cursor{X: 1, Y: 1}558+559+ next, _ := m.Update(tea.KeyPressMsg{Code: '0', Text: "0"})560+ got := next.(Model)561+ if got.grid[1][1] != zeroCell {562+ t.Fatalf("key 0 cell = %q, want %q", got.grid[1][1], zeroCell)563+ }564+565+ next, _ = got.Update(tea.KeyPressMsg{Code: '1', Text: "1"})566+ got = next.(Model)567+ if got.grid[1][1] != oneCell {568+ t.Fatalf("key 1 cell = %q, want %q", got.grid[1][1], oneCell)569+ }570+}571+572+func TestBridgeFillLeavesOpenInteriorUnfilledWithoutRelationCue(t *testing.T) {573+ m := testMouseModel()574+ m.cursor = game.Cursor{X: 0, Y: 1}575+576+ got := bridgeFill(m, game.DynamicGridBridge{577+ Kind: game.DynamicGridBridgeVertical,578+ X: 1,579+ Y: 1,580+ Count: 2,581+ Cells: [4]image.Point{582+ {X: 0, Y: 1},583+ {X: 1, Y: 1},584+ },585+ })586+ if got != nil {587+ t.Fatal("expected relation-free bridge to remain unfilled")588+ }589+}590+591+func TestBuildCountContextUsesCursorRowAndColumn(t *testing.T) {592+ m := testMouseModel()593+ m.cursor = game.Cursor{X: 3, Y: 0}594+595+ ctx := buildCountContext(m.grid, m.cursor, m.size)596+ if got, want := ctx.row, (countPair{zeros: 1, ones: 1}); got != want {597+ t.Fatalf("row counts = %+v, want %+v", got, want)598+ }599+ if got, want := ctx.col, (countPair{zeros: 1, ones: 1}); got != want {600+ t.Fatalf("col counts = %+v, want %+v", got, want)601+ }602+ if got, want := ctx.target, 2; got != want {603+ t.Fatalf("target = %d, want %d", got, want)604+ }605+}606+607+func TestCountContextViewHighlightsMetAndOverGoals(t *testing.T) {608+ m := Model{609+ size: 4,610+ grid: makeGrid4(611+ "0011",612+ "000.",613+ "....",614+ "....",615+ ),616+ cursor: game.Cursor{X: 0, Y: 1},617+ }618+619+ view := countContextView(m)620+ wantMet := metGoalCountStyle(zeroCell).Width(3).AlignHorizontal(lipgloss.Right).Render("2/2")621+ if !strings.Contains(view, wantMet) {622+ t.Fatalf("countContextView() = %q, want met-goal chip %q", view, wantMet)623+ }624+ wantOver := overGoalCountStyle().Width(3).AlignHorizontal(lipgloss.Right).Render("3/2")625+ if !strings.Contains(view, wantOver) {626+ t.Fatalf("countContextView() = %q, want over-goal chip %q", view, wantOver)627+ }628+ if !strings.Contains(view, string([]rune(renderRuneMap[zeroCell])[1])+":") {629+ t.Fatalf("countContextView() = %q, want zero symbol label", view)630+ }631+ if !strings.Contains(view, string([]rune(renderRuneMap[oneCell])[1])+":") {632+ t.Fatalf("countContextView() = %q, want one symbol label", view)633+ }634+ wantZeroLabel := countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":")635+ if !strings.Contains(view, wantZeroLabel) {636+ t.Fatalf("countContextView() = %q, want colored zero label %q", view, wantZeroLabel)637+ }638+ wantOneLabel := countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":")639+ if !strings.Contains(view, wantOneLabel) {640+ t.Fatalf("countContextView() = %q, want colored one label %q", view, wantOneLabel)641+ }642+}643+521644 func testMouseModel() Model {522645 return Model{523646 size: 4,