clone

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

more consistency passes

Dami committed b062913
commit
b062913cc5ec0d2214a970dac623bbf57e37dd32
parent
efb6a02fe7cd8da1a8d6945444ce00fdd7f8e4ae
author
Dami <dami.etoile@gmail.com>
17 files changed, +827 -133
Mfillomino/fillomino_test.go26++++
Mfillomino/style.go47++++--
Mrippleeffect/Gamemode.go10+-
Mrippleeffect/rippleeffect_test.go62+++++++--
Mrippleeffect/style.go13+--
Mshikaku/shikaku_test.go76+++++++++++
Mshikaku/style.go31+++--
Msudokurgb/dynamic_board_test.go20+++
Msudokurgb/style.go3+-
Mtakuzu/Model.go10+-
Mtakuzu/help.md7+-
Mtakuzu/style.go187++++++++++++++++++++++++---
Mtakuzu/takuzu_test.go126++++++++++++++++--
Mtakuzuplus/Model.go10+-
Mtakuzuplus/help.md4+
Mtakuzuplus/style.go205++++++++++++++++++++++++++----
Mtakuzuplus/takuzuplus_test.go123++++++++++++++++++
M fillomino/fillomino_test.go +26 -0
M fillomino/style.go +30 -17
@@ -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 {
M rippleeffect/Gamemode.go +5 -5
M rippleeffect/rippleeffect_test.go +45 -17
@@ -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
M rippleeffect/style.go +2 -11
@@ -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
M shikaku/shikaku_test.go +76 -0
@@ -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(),
M shikaku/style.go +19 -12
M sudokurgb/dynamic_board_test.go +20 -0
M sudokurgb/style.go +2 -1
M takuzu/Model.go +9 -1
M takuzu/help.md +6 -1
M takuzu/style.go +165 -22
@@ -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 }
M takuzu/takuzu_test.go +110 -16
@@ -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
M takuzuplus/Model.go +9 -1
M takuzuplus/help.md +4 -0
M takuzuplus/style.go +176 -29
@@ -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 }
M takuzuplus/takuzuplus_test.go +123 -0
@@ -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,