- commit
- efb6a02fe7cd8da1a8d6945444ce00fdd7f8e4ae
- parent
- 0f258df0de05b21cb7182a2ced194e3a6bc62a1b
- author
- Dami <dami.etoile@gmail.com>
| M | fillomino/ | 17 | ++ |
| M | fillomino/ | 13 | ++ |
| M | rippleeffect/ | 29 | +++ |
| M | rippleeffect/ | 4 | + |
| M | sudoku/ | 2 | +- |
| M | sudoku/ | 11 | +- |
| M | sudokurgb/ | 2 | +- |
| M | sudokurgb/ | 26 | ++- |
| A | sudokurgb/ | 103 | ++++++++++++ |
| M | sudokurgb/ | 10 | +- |
| M | sudokurgb/ | 255 | ++++++++++++++++++++---------- |
| M | sudokurgb/ | 15 | +- |
| M | sudokurgb/ | 5 | +- |
| M | sudokurgb/ | 250 | +++++++++++++++++------------ |
| M | sudokurgb/ | 118 | +++++++++++++- |
| M | takuzu/ | 12 | +- |
| M | takuzu/ | 29 | +++ |
| M | takuzuplus/ | 12 | +- |
| M | takuzuplus/ | 29 | +++ |
| M | theme/ | 12 | + |
| M | theme/ | 4 | +- |
| M | theme/ | 12 | + |
@@ -193,6 +193,23 @@ func TestCellViewConflictedCursorIsDistinct(t *testing.T) {
193193 }194194 }195195 196+func TestCellViewUsesGivenTintForProvidedDigits(t *testing.T) {197+ p := theme.Current()198+ text := lipgloss.NewStyle().Width(cellWidth).AlignHorizontal(lipgloss.Center).Render("3")199+200+ got := cellView(3, true, false, false, false, false, false, false, nil)201+ want := lipgloss.NewStyle().202+ Bold(true).203+ Foreground(p.FG).204+ Background(theme.GivenTint(p.BG)).205+ Width(cellWidth).206+ AlignHorizontal(lipgloss.Center).207+ Render(text)208+ if got != want {209+ t.Fatalf("provided cellView() = %q, want %q", got, want)210+ }211+}212+196213 func TestGridViewRendersRegionBoundaries(t *testing.T) {197214 m := Model{198215 width: 3,@@ -64,6 +64,19 @@ func cellView(
6464 style = style.Background(p.Surface)6565 }6666 67+ if provided && value != 0 && !conflict && !solved && !cursor {68+ bg := p.BG69+ switch {70+ case completedBG != nil:71+ bg = completedBG72+ case regionHighlight:73+ bg = p.HighlightBG74+ case rowHighlight || colHighlight:75+ bg = p.Surface76+ }77+ style = style.Background(theme.GivenTint(bg))78+ }79+6780 if cursor {6881 if value == 0 {6982 text = game.CursorLeft + "·" + game.CursorRight@@ -333,6 +333,35 @@ func TestCellViewConflictedCursorIsDistinct(t *testing.T) {
333333 }334334 }335335 336+func TestCellViewUsesGivenTintForImmutableGivens(t *testing.T) {337+ geo, err := buildGeometry(3, 3, sampleCages())338+ if err != nil {339+ t.Fatal(err)340+ }341+342+ m := Model{343+ width: 3,344+ height: 3,345+ grid: sampleSolution(),346+ givens: grid{{1, 0, 0}, {0, 0, 0}, {0, 0, 0}},347+ conflicts: newConflictGrid(3, 3),348+ cursor: pointCursor(2, 2),349+ geo: geo,350+ }351+352+ got := cellView(m, 0, 0, -1, nil)353+ want := lipgloss.NewStyle().354+ Width(cellWidth).355+ AlignHorizontal(lipgloss.Center).356+ Bold(true).357+ Foreground(theme.Current().FG).358+ Background(theme.GivenTint(theme.Current().BG)).359+ Render(" 1 ")360+ if got != want {361+ t.Fatalf("given cellView() = %q, want %q", got, want)362+ }363+}364+336365 func TestGridViewUsesUniformRowsForBronzeBirchState(t *testing.T) {337366 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"}`))338367 if err != nil {@@ -118,6 +118,10 @@ func cellView(m Model, x, y, activeCage int, completedBG color.Color) string {
118118 119119 if m.givens[y][x] != 0 {120120 style = style.Bold(true)121+ switch kind {122+ case visualNormal, visualCrosshair, visualCage, visualCompleted:123+ bg = theme.GivenTint(bg)124+ }121125 }122126 123127 return style.Foreground(fg).Background(bg).Render(text)@@ -185,7 +185,7 @@ func TestCellViewUsesSemanticDigitColors(t *testing.T) {
185185 wantProvided := lipgloss.NewStyle().186186 Bold(true).187187 Foreground(digitColor(7)).188- Background(p.BG).188+ Background(theme.GivenTint(p.BG)).189189 Width(cellWidth).190190 AlignHorizontal(lipgloss.Center).191191 Render("7")@@ -18,12 +18,11 @@ func emptyCellStyle() lipgloss.Style {
1818 Background(p.BG)1919 }2020 21-func providedCellStyle(v int) lipgloss.Style {22- p := theme.Current()21+func providedCellStyle(v int, bg color.Color) lipgloss.Style {2322 return lipgloss.NewStyle().2423 Bold(true).2524 Foreground(digitColor(v)).26- Background(p.BG)25+ Background(theme.GivenTint(bg))2726 }2827 2928 func userCellStyle(v int) lipgloss.Style {@@ -118,11 +117,11 @@ func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style
118117 119118 isProvided := m.providedGrid[y][x]120119 if isProvided {121- style := providedCellStyle(c.v)120+ bg := p.BG122121 if inCursorContext(m.cursor, x, y) {123- style = style.Background(p.Surface)122+ bg = p.Surface124123 }125- return style124+ return providedCellStyle(c.v, bg)126125 }127126 128127 if c.v != 0 {@@ -57,7 +57,7 @@ func ImportModel(data []byte) (*Model, error) {
5757 grid: g,5858 provided: provided,5959 providedGrid: buildProvidedGrid(provided),60- conflicts: computeConflicts(g),60+ analysis: analyzeGrid(g),6161 modeTitle: save.ModeTitle,6262 keys: DefaultKeyMap,6363 }, nil@@ -22,7 +22,7 @@ type Model struct {
2222 grid grid2323 provided []cell2424 providedGrid [gridSize][gridSize]bool25- conflicts [gridSize][gridSize]bool25+ analysis boardAnalysis2626 keys KeyMap2727 modeTitle string2828 showFullHelp bool@@ -47,7 +47,7 @@ func New(mode SudokuRGBMode, provided []cell) (game.Gamer, error) {
4747 grid: g,4848 provided: provided,4949 providedGrid: buildProvidedGrid(provided),50- conflicts: computeConflicts(g),50+ analysis: analyzeGrid(g),5151 keys: DefaultKeyMap,5252 modeTitle: mode.Title(),5353 }@@ -101,7 +101,7 @@ func (m Model) IsSolved() bool {
101101 102102 func (m Model) Reset() game.Gamer {103103 m.grid = newGrid(m.provided)104- m.conflicts = computeConflicts(m.grid)104+ m.analysis = analyzeGrid(m.grid)105105 m.originValid = false106106 return m107107 }@@ -113,28 +113,28 @@ func (m *Model) updateCell(value int) {
113113 114114 wasSolved := m.isSolved()115115 m.grid[m.cursor.Y][m.cursor.X].v = value116- m.conflicts = computeConflicts(m.grid)116+ m.analysis = analyzeGrid(m.grid)117117 if m.isSolved() != wasSolved {118118 m.originValid = false119119 }120120 }121121 122122 func (m Model) View() string {123- solved := isSolvedWith(m.grid, m.conflicts)123+ solved := isSolvedWith(m.grid, m.analysis)124124 title := game.TitleBarView("Sudoku RGB", m.modeTitle, solved)125- grid := renderGrid(m, solved)125+ board := buildBoardBlock(m, solved)126126 if solved {127- return game.ComposeGameView(title, grid)127+ return game.ComposeGameView(title, board.Block)128128 }129129 130130 status := statusBarView(m.showFullHelp)131- return game.ComposeGameViewRows(title, grid, game.StableRow(status, statusBarView(false), statusBarView(true)))131+ return game.ComposeGameViewRows(title, board.Block, game.StableRow(status, statusBarView(false), statusBarView(true)))132132 }133133 134134 func (m Model) GetDebugInfo() string {135135 cursorCell := m.grid[m.cursor.Y][m.cursor.X]136136 isProvided := m.providedGrid[m.cursor.Y][m.cursor.X]137- conflict := m.conflicts[m.cursor.Y][m.cursor.X]137+ boxConflict := m.analysis.boxConflictCells[m.cursor.Y][m.cursor.X]138138 solved := m.isSolved()139139 140140 filledCount := 0@@ -144,7 +144,7 @@ func (m Model) GetDebugInfo() string {
144144 if m.grid[y][x].v != 0 {145145 filledCount++146146 }147- if m.conflicts[y][x] {147+ if m.analysis.boxConflictCells[y][x] {148148 conflictCount++149149 }150150 }@@ -160,9 +160,11 @@ func (m Model) GetDebugInfo() string {
160160 {"Cursor", fmt.Sprintf("(%d, %d)", m.cursor.X, m.cursor.Y)},161161 {"Cell Value", cellDebugValue(cursorCell.v)},162162 {"Is Provided", fmt.Sprintf("%v", isProvided)},163- {"Has Conflict", fmt.Sprintf("%v", conflict)},163+ {"Box Conflict", fmt.Sprintf("%v", boxConflict)},164+ {"Row Counts", m.analysis.rowCountsString(m.cursor.Y)},165+ {"Col Counts", m.analysis.colCountsString(m.cursor.X)},164166 {"Cells Filled", fmt.Sprintf("%d / 81", filledCount)},165- {"Conflict Count", fmt.Sprintf("%d", conflictCount)},167+ {"Box Conflict Count", fmt.Sprintf("%d", conflictCount)},166168 {"Provided Count", fmt.Sprintf("%d", len(m.provided))},167169 })168170 }@@ -0,0 +1,103 @@
1+package sudokurgb2+3+type boardAnalysis struct {4+ rowCounts [gridSize][valueCount + 1]int5+ colCounts [gridSize][valueCount + 1]int6+ rowOverQuota [gridSize][valueCount + 1]bool7+ colOverQuota [gridSize][valueCount + 1]bool8+ boxConflictCells [gridSize][gridSize]bool9+}10+11+func analyzeGrid(g grid) boardAnalysis {12+ var analysis boardAnalysis13+14+ for y := range gridSize {15+ for x := range gridSize {16+ value := g[y][x].v17+ if value < 1 || value > valueCount {18+ continue19+ }20+ analysis.rowCounts[y][value]++21+ analysis.colCounts[x][value]++22+ }23+ }24+25+ for i := range gridSize {26+ for value := 1; value <= valueCount; value++ {27+ analysis.rowOverQuota[i][value] = analysis.rowCounts[i][value] > houseQuota28+ analysis.colOverQuota[i][value] = analysis.colCounts[i][value] > houseQuota29+ }30+ }31+32+ for boxY := range 3 {33+ for boxX := range 3 {34+ markBoxConflicts(&analysis, g, boxX, boxY)35+ }36+ }37+38+ return analysis39+}40+41+func markBoxConflicts(analysis *boardAnalysis, g grid, boxX, boxY int) {42+ type pos struct {43+ x int44+ y int45+ }46+47+ var seen [valueCount + 1][]pos48+ for dy := range 3 {49+ for dx := range 3 {50+ x, y := boxX*3+dx, boxY*3+dy51+ value := g[y][x].v52+ if value < 1 || value > valueCount {53+ continue54+ }55+ seen[value] = append(seen[value], pos{x: x, y: y})56+ }57+ }58+59+ for value := 1; value <= valueCount; value++ {60+ if len(seen[value]) <= houseQuota {61+ continue62+ }63+ for _, cell := range seen[value] {64+ analysis.boxConflictCells[cell.y][cell.x] = true65+ }66+ }67+}68+69+func (a boardAnalysis) hasConflicts() bool {70+ for i := range gridSize {71+ for value := 1; value <= valueCount; value++ {72+ if a.rowOverQuota[i][value] || a.colOverQuota[i][value] {73+ return true74+ }75+ }76+ }77+78+ for y := range gridSize {79+ for x := range gridSize {80+ if a.boxConflictCells[y][x] {81+ return true82+ }83+ }84+ }85+86+ return false87+}88+89+func (a boardAnalysis) rowCountsString(row int) string {90+ return countTripletString(a.rowCounts[row])91+}92+93+func (a boardAnalysis) colCountsString(col int) string {94+ return countTripletString(a.colCounts[col])95+}96+97+func countTripletString(counts [valueCount + 1]int) string {98+ return digitString(counts[1]) + "/" + digitString(counts[2]) + "/" + digitString(counts[3])99+}100+101+func digitString(v int) string {102+ return string(rune('0' + v))103+}@@ -27,18 +27,22 @@ func (c cell) String() string {
2727 }2828 2929 func (m Model) isSolved() bool {30- return isSolvedWith(m.grid, m.conflicts)30+ return isSolvedWith(m.grid, m.analysis)3131 }3232 33-func isSolvedWith(g grid, conflicts [gridSize][gridSize]bool) bool {33+func isSolvedWith(g grid, analysis boardAnalysis) bool {3434 for y := range gridSize {3535 for x := range gridSize {36- if g[y][x].v == 0 || conflicts[y][x] {36+ if g[y][x].v == 0 {3737 return false3838 }3939 }4040 }4141 42+ if analysis.hasConflicts() {43+ return false44+ }45+4246 for i := range gridSize {4347 if !houseHasExactQuota(rowValues(g, i)) {4448 return false@@ -1,102 +1,28 @@
11 package sudokurgb22 33 import (4- "image"5- "image/color"4+ "strings"65 "testing"76 87 "charm.land/lipgloss/v2"98 "github.com/FelineStateMachine/puzzletea/game"109 "github.com/FelineStateMachine/puzzletea/theme"10+ "github.com/charmbracelet/x/ansi"1111 )1212 13-func TestBridgeFillHighlightsCursorAxis(t *testing.T) {14- m := Model{cursor: game.Cursor{X: 1, Y: 1}}15- surface := theme.Current().Surface16-17- tests := []struct {18- name string19- bridge game.DynamicGridBridge20- want color.Color21- }{22- {23- name: "vertical bridge on cursor row",24- bridge: game.DynamicGridBridge{25- Kind: game.DynamicGridBridgeVertical,26- Cells: [4]image.Point{image.Pt(0, 1), image.Pt(1, 1)},27- Count: 2,28- },29- want: surface,30- },31- {32- name: "horizontal bridge on cursor column",33- bridge: game.DynamicGridBridge{34- Kind: game.DynamicGridBridgeHorizontal,35- Cells: [4]image.Point{image.Pt(1, 0), image.Pt(1, 1)},36- Count: 2,37- },38- want: surface,39- },40- {41- name: "junction stays neutral",42- bridge: game.DynamicGridBridge{43- Kind: game.DynamicGridBridgeJunction,44- Cells: [4]image.Point{image.Pt(1, 1)},45- Count: 1,46- },47- want: nil,48- },49- {50- name: "solved board disables bridge highlight",51- bridge: game.DynamicGridBridge{52- Kind: game.DynamicGridBridgeVertical,53- Cells: [4]image.Point{image.Pt(0, 1), image.Pt(1, 1)},54- Count: 2,55- },56- want: nil,57- },58- }59-60- for _, tt := range tests {61- t.Run(tt.name, func(t *testing.T) {62- solved := tt.name == "solved board disables bridge highlight"63- if got := bridgeFill(m, solved, tt.bridge); !game.SameColor(got, tt.want) {64- t.Fatalf("bridgeFill() = %v, want %v", got, tt.want)65- }66- })67- }68-}69-70-func TestActiveBoxZoneFillHighlightsOnlyActiveBox(t *testing.T) {71- cursor := game.Cursor{X: 1, Y: 1}72- surface := theme.Current().Surface73-74- if got := activeBoxZoneFill(cursor, false, 0); !game.SameColor(got, surface) {75- t.Fatalf("activeBoxZoneFill(in box) = %v, want %v", got, surface)76- }77- if got := activeBoxZoneFill(cursor, false, 1); got != nil {78- t.Fatalf("activeBoxZoneFill(out of box) = %v, want nil", got)79- }80- if got := activeBoxZoneFill(cursor, true, 0); got != nil {81- t.Fatalf("activeBoxZoneFill(solved) = %v, want nil", got)82- }83-}84-85-func TestCellViewHighlightsSameBoxAndCrosshair(t *testing.T) {13+func TestCellViewOmitsCrosshairAndBoxTint(t *testing.T) {8614 g := newGrid(nil)8715 g[2][2].v = 28816 g[1][4].v = 38917 m := Model{90- cursor: game.Cursor{X: 1, Y: 1},91- grid: g,92- keys: DefaultKeyMap,18+ cursor: game.Cursor{X: 1, Y: 1},19+ grid: g,20+ analysis: analyzeGrid(g),21+ keys: DefaultKeyMap,9322 }9423 95- surface := theme.Current().Surface96-9724 gotSameBox := cellView(m, 2, 2, false)9825 wantSameBox := userCellStyle(2).99- Background(surface).10026 Width(cellWidth).10127 AlignHorizontal(lipgloss.Center).10228 Render("■")@@ -106,7 +32,6 @@ func TestCellViewHighlightsSameBoxAndCrosshair(t *testing.T) {
10632 10733 gotSameRow := cellView(m, 4, 1, false)10834 wantSameRow := userCellStyle(3).109- Background(surface).11035 Width(cellWidth).11136 AlignHorizontal(lipgloss.Center).11237 Render("●")@@ -119,9 +44,10 @@ func TestCellViewUsesCursorBackground(t *testing.T) {
11944 g := newGrid(nil)12045 g[1][1].v = 212146 m := Model{122- cursor: game.Cursor{X: 1, Y: 1},123- grid: g,124- keys: DefaultKeyMap,47+ cursor: game.Cursor{X: 1, Y: 1},48+ grid: g,49+ analysis: analyzeGrid(g),50+ keys: DefaultKeyMap,12551 }12652 12753 gotFilled := cellView(m, 1, 1, false)@@ -143,3 +69,162 @@ func TestCellViewUsesCursorBackground(t *testing.T) {
14369 t.Fatalf("empty cursor cellView() = %q, want %q", gotEmpty, wantEmpty)14470 }14571 }72+73+func TestProvidedCellViewUsesGivenTint(t *testing.T) {74+ g := newGrid([]cell{{x: 0, y: 0, v: 2}})75+ m := Model{76+ grid: g,77+ keys: DefaultKeyMap,78+ cursor: game.Cursor{X: 8, Y: 8},79+ analysis: analyzeGrid(g),80+ providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 2}}),81+ }82+83+ got := cellView(m, 0, 0, false)84+ want := lipgloss.NewStyle().85+ Bold(true).86+ Foreground(symbolColor(2)).87+ Background(theme.GivenTint(game.DefaultBorderColors().BackgroundBG)).88+ Width(cellWidth).89+ AlignHorizontal(lipgloss.Center).90+ Render("■")91+ if got != want {92+ t.Fatalf("provided cellView() = %q, want %q", got, want)93+ }94+}95+96+func TestBuildBoardBlockRendersHintBands(t *testing.T) {97+ g := newGrid(nil)98+ g[0][0].v = 199+ g[0][1].v = 2100+ g[0][2].v = 3101+ m := Model{102+ grid: g,103+ analysis: analyzeGrid(g),104+ keys: DefaultKeyMap,105+ }106+107+ layout := buildBoardBlock(m, false)108+ lines := strings.Split(ansi.Strip(layout.Block), "\n")109+ if got, want := layout.HintHeight, colHintHeight; got != want {110+ t.Fatalf("HintHeight = %d, want %d", got, want)111+ }112+ if got, want := layout.HintWidth, rowHintWidth; got != want {113+ t.Fatalf("HintWidth = %d, want %d", got, want)114+ }115+ if got := []rune(lines[layout.HintHeight])[layout.HintWidth]; got != '┌' {116+ t.Fatalf("grid top border should start after hints, got %q", got)117+ }118+ if strings.TrimSpace(lines[layout.HintHeight][:layout.HintWidth]) != "" {119+ t.Fatalf("row hints should not occupy the border row, got %q", lines[layout.HintHeight][:layout.HintWidth])120+ }121+ if strings.TrimSpace(lines[layout.HintHeight+1][:layout.HintWidth]) == "" {122+ t.Fatalf("row hints missing on first content row: %q", lines[layout.HintHeight+1][:layout.HintWidth])123+ }124+ if strings.TrimSpace(lines[0][layout.HintWidth:]) == "" {125+ t.Fatalf("column hints missing from top band: %q", lines[0])126+ }127+}128+129+func TestHintRenderingUsesSolvedAndErrorStyles(t *testing.T) {130+ analysis := boardAnalysis{}131+ analysis.rowCounts[0][1] = houseQuota132+ analysis.rowCounts[0][2] = 2133+ analysis.rowCounts[0][3] = 4134+ analysis.rowOverQuota[0][3] = true135+ analysis.colCounts[0][2] = houseQuota136+137+ rowHints := rowHintView(analysis, false)138+ wantSolved := hintSolvedStyle(1).Render("3")139+ if !strings.Contains(rowHints, wantSolved) {140+ t.Fatalf("rowHintView() = %q, want solved chip %q", rowHints, wantSolved)141+ }142+ wantError := hintErrorStyle().Render("4")143+ if !strings.Contains(rowHints, wantError) {144+ t.Fatalf("rowHintView() = %q, want error chip %q", rowHints, wantError)145+ }146+147+ colHints := colHintView(analysis, false)148+ wantColSolved := hintSolvedStyle(2).Render("3")149+ if !strings.Contains(colHints, wantColSolved) {150+ t.Fatalf("colHintView() = %q, want solved chip %q", colHints, wantColSolved)151+ }152+}153+154+func TestSolvedBoardUsesSuccessBackground(t *testing.T) {155+ p := theme.Current()156+ g := validCompleteGrid()157+ m := Model{158+ grid: g,159+ analysis: analyzeGrid(g),160+ keys: DefaultKeyMap,161+ cursor: game.Cursor{X: 8, Y: 8},162+ }163+164+ gotCell := cellView(m, 0, 0, true)165+ wantCell := userCellStyle(1).166+ Bold(true).167+ Foreground(p.SolvedFG).168+ Background(p.SuccessBG).169+ Width(cellWidth).170+ AlignHorizontal(lipgloss.Center).171+ Render("▲")172+ if gotCell != wantCell {173+ t.Fatalf("solved cellView() = %q, want %q", gotCell, wantCell)174+ }175+176+ gotRowHints := rowHintView(m.analysis, true)177+ wantRowHint := solvedBoardStyle().Render("3")178+ if !strings.Contains(gotRowHints, wantRowHint) {179+ t.Fatalf("solved rowHintView() = %q, want success background %q", gotRowHints, wantRowHint)180+ }181+182+ gotColHints := colHintView(m.analysis, true)183+ if !strings.Contains(gotColHints, wantRowHint) {184+ t.Fatalf("solved colHintView() = %q, want success background %q", gotColHints, wantRowHint)185+ }186+187+ wantSpacer := solvedBoardStyle().Render(" ")188+ if !strings.Contains(gotRowHints, wantSpacer) {189+ t.Fatalf("solved rowHintView() = %q, want solved spacer %q", gotRowHints, wantSpacer)190+ }191+}192+193+func TestCellViewUsesErrorBackgroundForBoxConflictOnly(t *testing.T) {194+ g := newGrid(nil)195+ g[0][0].v = 3196+ g[0][1].v = 3197+ g[1][2].v = 3198+ g[2][0].v = 3199+200+ m := Model{201+ grid: g,202+ analysis: analyzeGrid(g),203+ keys: DefaultKeyMap,204+ cursor: game.Cursor{X: 8, Y: 8},205+ }206+207+ got := cellView(m, 0, 0, false)208+ want := boxConflictCellStyle().209+ Width(cellWidth).210+ AlignHorizontal(lipgloss.Center).211+ Render("●")212+ if got != want {213+ t.Fatalf("box conflict cellView() = %q, want %q", got, want)214+ }215+216+ rowOnly := newGrid(nil)217+ for x := 0; x < 4; x++ {218+ rowOnly[0][x].v = 1219+ }220+ m.grid = rowOnly221+ m.analysis = analyzeGrid(rowOnly)222+ got = cellView(m, 0, 0, false)223+ want = userCellStyle(1).224+ Width(cellWidth).225+ AlignHorizontal(lipgloss.Center).226+ Render("▲")227+ if got != want {228+ t.Fatalf("row-only conflict cellView() = %q, want %q", got, want)229+ }230+}@@ -8,8 +8,9 @@ Fill a 9x9 grid with RGB symbols so every row, column, and box matches the same
88 - Each **column** must contain `{1,1,1,2,2,2,3,3,3}`.99 - Each **3x3 box** must contain `{1,1,1,2,2,2,3,3,3}`.1010 - `1`, `2`, and `3` map to `▲`, `■`, and `●`.11-- Pre-filled cells cannot be changed. Cells only conflict when a row, column,12- or box contains **more than three** of one value.11+- The RGB hint counts show the live total for each row and column.12+- Pre-filled cells cannot be changed. Row and column over-quota conflicts are13+ shown on the hint counts. Only 3x3 box conflicts highlight cell backgrounds.1314 1415 Inspired by Ripeto.1516 @@ -28,8 +29,8 @@ Inspired by Ripeto.
2829 ## Tips2930 3031 - **Count aggressively.** Every house wants exactly three of each value, so31- once a row already has three `▲`, the rest of that row cannot be `▲`.32-- **Cross-check rows and boxes.** A value capped out in both the row and the33- box removes options quickly.34-- **Use conflicts as quota warnings.** Red highlights mean a house already has35- too many copies of a value.32+ once a row already has three `▲`, the solved hint chip marks that value.33+- **Cross-check rows and boxes.** Use the row and column RGB counts to narrow34+ options before you look for box-only conflicts.35+- **Watch where errors appear.** Hint-count errors mean a row or column is36+ over quota; red cell backgrounds mean the problem is inside a 3x3 box.@@ -28,6 +28,7 @@ func (m *Model) cachedGridOrigin() (x, y int) {
2828 func (m *Model) gridOrigin() (x, y int) {2929 solved := m.isSolved()3030 title := game.TitleBarView("Sudoku RGB", m.modeTitle, solved)31- grid := renderGrid(*m, solved)32- return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)31+ board := buildBoardBlock(*m, solved)32+ x, y = game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, board.Block)33+ return x + board.HintWidth, y + board.HintHeight3334 }@@ -2,13 +2,19 @@ package sudokurgb
22 33 import (44 "image/color"5+ "strconv"6+ "strings"57 68 "charm.land/lipgloss/v2"79 "github.com/FelineStateMachine/puzzletea/game"810 "github.com/FelineStateMachine/puzzletea/theme"911 )1012 11-const cellWidth = game.DynamicGridCellWidth13+const (14+ cellWidth = game.DynamicGridCellWidth15+ rowHintWidth = valueCount*2 - 116+ colHintHeight = valueCount17+)1218 1319 func emptyCellStyle() lipgloss.Style {1420 p := theme.Current()@@ -17,10 +23,11 @@ func emptyCellStyle() lipgloss.Style {
1723 Background(game.DefaultBorderColors().BackgroundBG)1824 }1925 20-func providedCellStyle() lipgloss.Style {26+func providedCellStyle(value int, bg color.Color) lipgloss.Style {2127 return lipgloss.NewStyle().2228 Bold(true).23- Background(game.DefaultBorderColors().BackgroundBG)29+ Foreground(symbolColor(value)).30+ Background(theme.GivenTint(bg))2431 }2532 2633 func userCellStyle(value int) lipgloss.Style {@@ -28,11 +35,10 @@ func userCellStyle(value int) lipgloss.Style {
2835 Foreground(symbolColor(value))2936 }3037 31-func conflictCellStyle() lipgloss.Style {32- p := theme.Current()38+func boxConflictCellStyle() lipgloss.Style {3339 return lipgloss.NewStyle().34- Foreground(p.Error).35- Underline(true)40+ Foreground(theme.TextOnBG(game.ConflictBG())).41+ Background(game.ConflictBG())3642 }3743 3844 func valueCursorStyle(value int) lipgloss.Style {@@ -47,6 +53,63 @@ func valueCursorStyle(value int) lipgloss.Style {
4753 Background(bg)4854 }4955 56+func hintStyle(value int) lipgloss.Style {57+ return lipgloss.NewStyle().Foreground(symbolColor(value))58+}59+60+func hintSolvedStyle(value int) lipgloss.Style {61+ bg := symbolColor(value)62+ return lipgloss.NewStyle().63+ Bold(true).64+ Foreground(theme.TextOnBG(bg)).65+ Background(bg)66+}67+68+func hintErrorStyle() lipgloss.Style {69+ return lipgloss.NewStyle().70+ Bold(true).71+ Foreground(theme.TextOnBG(game.ConflictBG())).72+ Background(game.ConflictBG())73+}74+75+func solvedBoardStyle() lipgloss.Style {76+ p := theme.Current()77+ return lipgloss.NewStyle().78+ Foreground(p.SolvedFG).79+ Background(p.SuccessBG)80+}81+82+type boardBlockLayout struct {83+ Block string84+ Grid string85+ HintWidth int86+ HintHeight int87+}88+89+func buildBoardBlock(m Model, solved bool) boardBlockLayout {90+ grid := renderGrid(m, solved)91+ rowHints := rowHintView(m.analysis, solved)92+ colHints := colHintView(m.analysis, solved)93+ spacerStyle := lipgloss.NewStyle()94+ if solved {95+ spacerStyle = solvedBoardStyle()96+ }97+ spacer := spacerStyle.Width(lipgloss.Width(rowHints)).Height(colHintHeight).Render("")98+ topBand := lipgloss.JoinHorizontal(lipgloss.Top, spacer, colHints)99+ block := lipgloss.JoinVertical(100+ lipgloss.Left,101+ topBand,102+ lipgloss.JoinHorizontal(lipgloss.Top, rowHints, grid),103+ )104+105+ return boardBlockLayout{106+ Block: block,107+ Grid: grid,108+ HintWidth: lipgloss.Width(rowHints),109+ HintHeight: lipgloss.Height(topBand),110+ }111+}112+50113 func renderGrid(m Model, solved bool) string {51114 return game.RenderDynamicGrid(game.DynamicGridSpec{52115 Width: gridSize,@@ -58,18 +121,12 @@ func renderGrid(m Model, solved bool) string {
58121 ZoneAt: func(x, y int) int {59122 return sudokuBoxIndex(x, y)60123 },61- ZoneFill: func(zone int) color.Color {62- return activeBoxZoneFill(m.cursor, solved, zone)63- },64- BridgeFill: func(bridge game.DynamicGridBridge) color.Color {65- return bridgeFill(m, solved, bridge)66- },67124 })68125 }69126 70127 func cellView(m Model, x, y int, solved bool) string {71128 c := m.grid[y][x]72- style := cellStyle(m, c, x, y, m.conflicts[y][x], solved)129+ style := cellStyle(m, c, x, y, solved)73130 text := cellContent(c)74131 if x == m.cursor.X && y == m.cursor.Y {75132 if c.v == 0 {@@ -82,8 +139,7 @@ func cellView(m Model, x, y int, solved bool) string {
82139 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)83140 }84141 85-func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style {86- p := theme.Current()142+func cellStyle(m Model, c cell, x, y int, solved bool) lipgloss.Style {87143 isCursor := m.cursor.X == x && m.cursor.Y == y88144 var base lipgloss.Style89145 @@ -95,53 +151,26 @@ func cellStyle(m Model, c cell, x, y int, conflict, solved bool) lipgloss.Style
95151 }96152 97153 if m.providedGrid[y][x] {98- base = providedCellStyle()99- if c.v != 0 {100- base = base.Foreground(symbolColor(c.v))101- }102- }103-104- if !solved && !isCursor && inCursorContext(m.cursor, x, y) {105- base = base.Background(p.Surface)154+ base = providedCellStyle(c.v, game.DefaultBorderColors().BackgroundBG)106155 }107156 108157 switch {109158 case isCursor:110159 return valueCursorStyle(c.v)111- case conflict:112- base = base.Inherit(conflictCellStyle())160+ case m.analysis.boxConflictCells[y][x]:161+ base = base.162+ Foreground(theme.TextOnBG(game.ConflictBG())).163+ Background(game.ConflictBG())113164 case solved:114- base = base.Bold(true)165+ base = base.166+ Bold(true).167+ Foreground(theme.Current().SolvedFG).168+ Background(theme.Current().SuccessBG)115169 }116170 117171 return base118172 }119173 120-func bridgeFill(m Model, solved bool, bridge game.DynamicGridBridge) color.Color {121- if solved {122- return nil123- }124-125- if game.DynamicGridBridgeOnCrosshairAxis(m.cursor, bridge) {126- return theme.Current().Surface127- }128-129- return nil130-}131-132-func activeBoxZoneFill(cursor game.Cursor, solved bool, zone int) color.Color {133- if solved || zone != sudokuBoxIndex(cursor.X, cursor.Y) {134- return nil135- }136- return theme.Current().Surface137-}138-139-func inCursorContext(cursor game.Cursor, x, y int) bool {140- return cursor.X == x ||141- cursor.Y == y ||142- sudokuBoxIndex(cursor.X, cursor.Y) == sudokuBoxIndex(x, y)143-}144-145174 func sudokuBoxIndex(x, y int) int {146175 return (y / 3 * 3) + (x / 3)147176 }@@ -180,67 +209,84 @@ func symbolColor(value int) color.Color {
180209 }181210 }182211 183-func computeConflicts(g grid) [gridSize][gridSize]bool {184- var conflicts [gridSize][gridSize]bool212+func rowHintView(analysis boardAnalysis, solved bool) string {213+ lines := make([]string, 0, gridSize*2+1)214+ blankStyle := lipgloss.NewStyle()215+ if solved {216+ blankStyle = solvedBoardStyle()217+ }218+ blank := blankStyle.Width(rowHintWidth).Render("")219+ lines = append(lines, blank)220+ for row := range gridSize {221+ lines = append(lines, renderRowHintCounts(analysis, row, solved))222+ lines = append(lines, blank)223+ }224+ return strings.Join(lines, "\n")225+}185226 186- for y := range gridSize {187- var seen [valueCount + 1][]int188- for x := range gridSize {189- value := g[y][x].v190- if value != 0 {191- seen[value] = append(seen[value], x)192- }193- }194- for value := 1; value <= valueCount; value++ {195- if len(seen[value]) > houseQuota {196- for _, x := range seen[value] {197- conflicts[y][x] = true198- }199- }200- }227+func renderRowHintCounts(analysis boardAnalysis, row int, solved bool) string {228+ var b strings.Builder229+ spacerStyle := lipgloss.NewStyle()230+ if solved {231+ spacerStyle = solvedBoardStyle()201232 }202233 203- for x := range gridSize {204- var seen [valueCount + 1][]int205- for y := range gridSize {206- value := g[y][x].v207- if value != 0 {208- seen[value] = append(seen[value], y)209- }210- }211- for value := 1; value <= valueCount; value++ {212- if len(seen[value]) > houseQuota {213- for _, y := range seen[value] {214- conflicts[y][x] = true215- }216- }234+ for value := 1; value <= valueCount; value++ {235+ if value > 1 {236+ b.WriteString(spacerStyle.Render(" "))217237 }238+ b.WriteString(renderHintCount(analysis.rowCounts[row][value], value, analysis.rowOverQuota[row][value], solved))218239 }219240 220- for boxY := range 3 {221- for boxX := range 3 {222- type pos struct{ x, y int }223- var seen [valueCount + 1][]pos224- for dy := range 3 {225- for dx := range 3 {226- x, y := boxX*3+dx, boxY*3+dy227- value := g[y][x].v228- if value != 0 {229- seen[value] = append(seen[value], pos{x: x, y: y})230- }231- }241+ rowStyle := lipgloss.NewStyle()242+ if solved {243+ rowStyle = solvedBoardStyle()244+ }245+ return rowStyle.Width(rowHintWidth).Render(b.String())246+}247+248+func colHintView(analysis boardAnalysis, solved bool) string {249+ lines := make([]string, 0, colHintHeight)250+ for value := 1; value <= valueCount; value++ {251+ var b strings.Builder252+ lineStyle := lipgloss.NewStyle()253+ if solved {254+ lineStyle = solvedBoardStyle()255+ }256+ b.WriteString(lineStyle.Render(" "))257+ for col := range gridSize {258+ cellStyle := lipgloss.NewStyle().259+ Width(cellWidth).260+ AlignHorizontal(lipgloss.Center)261+ if solved {262+ cellStyle = cellStyle.Inherit(solvedBoardStyle())232263 }233- for value := 1; value <= valueCount; value++ {234- if len(seen[value]) > houseQuota {235- for _, p := range seen[value] {236- conflicts[p.y][p.x] = true237- }238- }264+ b.WriteString(265+ cellStyle.Render(renderHintCount(analysis.colCounts[col][value], value, analysis.colOverQuota[col][value], solved)),266+ )267+ if col < gridSize-1 {268+ b.WriteString(lineStyle.Render(" "))239269 }240270 }271+ b.WriteString(lineStyle.Render(" "))272+ lines = append(lines, b.String())273+ }274+ return strings.Join(lines, "\n")275+}276+277+func renderHintCount(count, value int, overQuota, solved bool) string {278+ if solved {279+ return solvedBoardStyle().Render(strconv.Itoa(count))241280 }242281 243- return conflicts282+ style := hintStyle(value)283+ if count == houseQuota {284+ style = hintSolvedStyle(value)285+ }286+ if overQuota {287+ style = hintErrorStyle()288+ }289+ return style.Render(strconv.Itoa(count))244290 }245291 246292 func statusBarView(showFullHelp bool) string {@@ -37,7 +37,7 @@ func TestFillGridSeededProducesValidBoard(t *testing.T) {
3737 if !fillGridSeeded(&g, rand.New(rand.NewPCG(7, 11))) {3838 t.Fatal("fillGridSeeded returned false")3939 }40- if conflicts := computeConflicts(g); !isSolvedWith(g, conflicts) {40+ if analysis := analyzeGrid(g); !isSolvedWith(g, analysis) {4141 t.Fatal("generated board is not solved")4242 }4343 }@@ -73,7 +73,7 @@ func TestModelUpdateRestrictsInputsAndRespectsProvidedCells(t *testing.T) {
7373 grid: newGrid([]cell{{x: 0, y: 0, v: 1}}),7474 provided: []cell{{x: 0, y: 0, v: 1}},7575 providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),76- conflicts: computeConflicts(newGrid([]cell{{x: 0, y: 0, v: 1}})),76+ analysis: analyzeGrid(newGrid([]cell{{x: 0, y: 0, v: 1}})),7777 keys: DefaultKeyMap,7878 cursor: game.Cursor{X: 1, Y: 1},7979 }@@ -108,9 +108,10 @@ func TestCellViewUsesUnicodeSymbolsWithCursorMarkers(t *testing.T) {
108108 g := newGrid(nil)109109 g[0][0].v = 3110110 m := Model{111- grid: g,112- keys: DefaultKeyMap,113- cursor: game.Cursor{X: 0, Y: 0},111+ grid: g,112+ keys: DefaultKeyMap,113+ cursor: game.Cursor{X: 0, Y: 0},114+ analysis: analyzeGrid(g),114115 }115116 116117 got := ansi.Strip(cellView(m, 0, 0, false))@@ -128,9 +129,9 @@ func TestCellViewUsesUnicodeSymbolsWithCursorMarkers(t *testing.T) {
128129 129130 func TestScreenToGridUsesStandardCellWidth(t *testing.T) {130131 m := Model{131- grid: newGrid(nil),132- conflicts: computeConflicts(newGrid(nil)),133- keys: DefaultKeyMap,132+ grid: newGrid(nil),133+ analysis: analyzeGrid(newGrid(nil)),134+ keys: DefaultKeyMap,134135 }135136 136137 next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})@@ -143,6 +144,12 @@ func TestScreenToGridUsesStandardCellWidth(t *testing.T) {
143144 if _, _, ok := got.screenToGrid(x+cellWidth, y); ok {144145 t.Fatal("screenToGrid(separator) = ok, want false")145146 }147+ if _, _, ok := got.screenToGrid(x-rowHintWidth, y); ok {148+ t.Fatal("screenToGrid(row hint band) = ok, want false")149+ }150+ if _, _, ok := got.screenToGrid(x, y-colHintHeight); ok {151+ t.Fatal("screenToGrid(column hint band) = ok, want false")152+ }146153 }147154 148155 func TestSaveRoundTrip(t *testing.T) {@@ -153,7 +160,7 @@ func TestSaveRoundTrip(t *testing.T) {
153160 grid: g,154161 provided: []cell{{x: 0, y: 0, v: 1}},155162 providedGrid: buildProvidedGrid([]cell{{x: 0, y: 0, v: 1}}),156- conflicts: computeConflicts(g),163+ analysis: analyzeGrid(g),157164 keys: DefaultKeyMap,158165 modeTitle: "Easy",159166 }@@ -174,3 +181,96 @@ func TestSaveRoundTrip(t *testing.T) {
174181 t.Fatalf("mode title = %q, want Easy", loaded.modeTitle)175182 }176183 }184+185+func TestAnalyzeGridTracksRowColumnAndBoxState(t *testing.T) {186+ t.Run("counts solved board", func(t *testing.T) {187+ analysis := analyzeGrid(validCompleteGrid())188+ for row := range gridSize {189+ if got := analysis.rowCountsString(row); got != "3/3/3" {190+ t.Fatalf("rowCountsString(%d) = %q, want 3/3/3", row, got)191+ }192+ }193+ for col := range gridSize {194+ if got := analysis.colCountsString(col); got != "3/3/3" {195+ t.Fatalf("colCountsString(%d) = %q, want 3/3/3", col, got)196+ }197+ }198+ if analysis.hasConflicts() {199+ t.Fatal("solved board unexpectedly has conflicts")200+ }201+ })202+203+ t.Run("row over-quota stays off cells", func(t *testing.T) {204+ g := newGrid(nil)205+ for x := 0; x < 4; x++ {206+ g[0][x].v = 1207+ }208+209+ analysis := analyzeGrid(g)210+ if !analysis.rowOverQuota[0][1] {211+ t.Fatal("expected row over-quota for red")212+ }213+ if analysis.colOverQuota[0][1] {214+ t.Fatal("unexpected column over-quota")215+ }216+ for y := range gridSize {217+ for x := range gridSize {218+ if analysis.boxConflictCells[y][x] {219+ t.Fatalf("unexpected box conflict at (%d,%d)", x, y)220+ }221+ }222+ }223+ })224+225+ t.Run("column over-quota stays off cells", func(t *testing.T) {226+ g := newGrid(nil)227+ for _, y := range []int{0, 3, 6, 8} {228+ g[y][0].v = 2229+ }230+231+ analysis := analyzeGrid(g)232+ if !analysis.colOverQuota[0][2] {233+ t.Fatal("expected column over-quota for green")234+ }235+ if analysis.rowOverQuota[0][2] {236+ t.Fatal("unexpected row over-quota")237+ }238+ for y := range gridSize {239+ for x := range gridSize {240+ if analysis.boxConflictCells[y][x] {241+ t.Fatalf("unexpected box conflict at (%d,%d)", x, y)242+ }243+ }244+ }245+ })246+247+ t.Run("box over-quota marks only box cells", func(t *testing.T) {248+ g := newGrid(nil)249+ for _, cell := range []cell{250+ {x: 0, y: 0, v: 3},251+ {x: 1, y: 0, v: 3},252+ {x: 2, y: 1, v: 3},253+ {x: 0, y: 2, v: 3},254+ } {255+ g[cell.y][cell.x] = cell256+ }257+258+ analysis := analyzeGrid(g)259+ if analysis.rowOverQuota[0][3] || analysis.colOverQuota[0][3] {260+ t.Fatal("box-only conflict leaked into row or column state")261+ }262+ for _, cell := range []cell{263+ {x: 0, y: 0},264+ {x: 1, y: 0},265+ {x: 2, y: 1},266+ {x: 0, y: 2},267+ } {268+ if !analysis.boxConflictCells[cell.y][cell.x] {269+ t.Fatalf("expected box conflict at (%d,%d)", cell.x, cell.y)270+ }271+ }272+ if analysis.boxConflictCells[3][3] {273+ t.Fatal("unexpected box conflict outside offending box")274+ }275+ })276+}@@ -47,10 +47,6 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
4747 style = emptyStyle()4848 }4949 50- if isProvided && val != emptyCell {51- style = style.Bold(true)52- }53-5450 text, ok := renderRuneMap[val]5551 if !ok {5652 text = renderRuneMap[emptyCell]@@ -69,6 +65,14 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
6965 style = style.Background(p.Surface)7066 }7167 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))74+ }75+7276 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)7377 }7478 @@ -8,6 +8,7 @@ import (
88 "testing"99 1010 tea "charm.land/bubbletea/v2"11+ "charm.land/lipgloss/v2"1112 "github.com/FelineStateMachine/puzzletea/game"1213 "github.com/FelineStateMachine/puzzletea/theme"1314 )@@ -1010,6 +1011,34 @@ func TestMouseClickSameCellDoesNotCycleProvidedCell(t *testing.T) {
10101011 }10111012 }10121013 1014+func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) {1015+ p := theme.Current()1016+1017+ gotZero := cellView(zeroCell, true, false, false, false, false)1018+ wantZero := lipgloss.NewStyle().1019+ Bold(true).1020+ Foreground(p.Accent).1021+ Background(theme.GivenTint(p.BG)).1022+ Width(cellWidth).1023+ AlignHorizontal(lipgloss.Center).1024+ Render(renderRuneMap[zeroCell])1025+ if gotZero != wantZero {1026+ t.Fatalf("provided zero cellView() = %q, want %q", gotZero, wantZero)1027+ }1028+1029+ gotOne := cellView(oneCell, true, false, false, false, false)1030+ wantOne := lipgloss.NewStyle().1031+ Bold(true).1032+ Foreground(p.Secondary).1033+ Background(theme.GivenTint(p.BG)).1034+ Width(cellWidth).1035+ AlignHorizontal(lipgloss.Center).1036+ Render(renderRuneMap[oneCell])1037+ if gotOne != wantOne {1038+ t.Fatalf("provided one cellView() = %q, want %q", gotOne, wantOne)1039+ }1040+}1041+10131042 func TestBridgeFillUsesVerticalCrosshairAcrossOpenInterior(t *testing.T) {10141043 m := testMouseModel()10151044 m.cursor = game.Cursor{X: 0, Y: 1}@@ -47,10 +47,6 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
4747 style = emptyStyle()4848 }4949 50- if isProvided && val != emptyCell {51- style = style.Bold(true)52- }53-5450 text, ok := renderRuneMap[val]5551 if !ok {5652 text = renderRuneMap[emptyCell]@@ -69,6 +65,14 @@ func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved b
6965 style = style.Background(p.Surface)7066 }7167 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))74+ }75+7276 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)7377 }7478 @@ -9,6 +9,7 @@ import (
99 "testing"1010 1111 tea "charm.land/bubbletea/v2"12+ "charm.land/lipgloss/v2"1213 "github.com/FelineStateMachine/puzzletea/game"1314 "github.com/FelineStateMachine/puzzletea/theme"1415 "github.com/charmbracelet/x/ansi"@@ -489,6 +490,34 @@ func TestRelationBridgeBackgroundIncompleteIsNeutral(t *testing.T) {
489490 }490491 }491492 493+func TestCellViewUsesGivenTintForProvidedCells(t *testing.T) {494+ p := theme.Current()495+496+ gotZero := cellView(zeroCell, true, false, false, false, false)497+ wantZero := lipgloss.NewStyle().498+ Bold(true).499+ Foreground(p.Accent).500+ Background(theme.GivenTint(p.BG)).501+ Width(cellWidth).502+ AlignHorizontal(lipgloss.Center).503+ Render(renderRuneMap[zeroCell])504+ if gotZero != wantZero {505+ t.Fatalf("provided zero cellView() = %q, want %q", gotZero, wantZero)506+ }507+508+ gotOne := cellView(oneCell, true, false, false, false, false)509+ wantOne := lipgloss.NewStyle().510+ Bold(true).511+ Foreground(p.Secondary).512+ Background(theme.GivenTint(p.BG)).513+ Width(cellWidth).514+ AlignHorizontal(lipgloss.Center).515+ Render(renderRuneMap[oneCell])516+ if gotOne != wantOne {517+ t.Fatalf("provided one cellView() = %q, want %q", gotOne, wantOne)518+ }519+}520+492521 func testMouseModel() Model {493522 return Model{494523 size: 4,@@ -41,6 +41,8 @@ const minContrastNormal = 4.5
4141 // minContrastLarge is the WCAG AA minimum for large/bold text (3:1).4242 const minContrastLarge = 3.04343 44+const givenTintBlend = 0.2545+4446 // ensurePairContrast ensures that the foreground/background pair meets the4547 // minimum contrast ratio. When the original fg falls short, it scans the4648 // full 16-color ANSI palette for the candidate that meets the ratio and@@ -148,6 +150,16 @@ func TextOnBG(bg color.Color) color.Color {
148150 return p.BG149151 }150152 153+// GivenTint shifts a color toward the current palette's Given color while154+// preserving some of the original hue. This is used for immutable-cell155+// styling cues in editable grids.156+func GivenTint(c color.Color) color.Color {157+ if c == nil {158+ return Current().Given159+ }160+ return Blend(c, Current().Given, givenTintBlend)161+}162+151163 // Blend returns a color that is t fraction of the way from a toward b.152164 // t=0 returns a, t=1 returns b. Used to derive muted background colors153165 // from foreground accent colors.@@ -157,11 +157,11 @@ func derivePalette(t Theme) Palette {
157157 SolvedFG: ansi[15], // brightWhite — text on state BGs158158 Error: errorFG,159159 Info: ansi[12], // brightBlue160- Given: ansi[5], // purple160+ Given: ansi[13], // brightPurple161161 Linked: linked,162162 Highlight: highlight,163163 Secondary: secondary,164- Tertiary: ansi[13], // brightPurple164+ Tertiary: ansi[5], // purple165165 166166 AccentBG: accentBG,167167 SuccessBG: successBG,@@ -233,3 +233,15 @@ func TestMidTone(t *testing.T) {
233233 t.Errorf("midTone(black, white) = %s, want 127,127,127", got)234234 }235235 }236+237+func TestGivenTint(t *testing.T) {238+ base := color.NRGBA{R: 24, G: 80, B: 160, A: 255}239+ got := GivenTint(base)240+ want := Blend(base, Current().Given, givenTintBlend)241+242+ gr, gg, gb, ga := got.RGBA()243+ wr, wg, wb, wa := want.RGBA()244+ if gr != wr || gg != wg || gb != wb || ga != wa {245+ t.Fatalf("GivenTint() = %v, want %v", got, want)246+ }247+}