- commit
- 7471ee00ebe17f1659a5ad8a7600f262b2495b81
- parent
- f6b7327e6785db421e1856dec4e55082e7f87cc6
- author
- Dami <dami.etoile@gmail.com>
| M | fillomino/ | 22 | ++ |
| M | fillomino/ | 69 | +----- |
| M | fillomino/ | 165 | ++++++++----- |
| A | game/ | 374 | ++++++++++++++++++++++++++++++ |
| A | game/ | 153 | ++++++++++++ |
| M | rippleeffect/ | 177 | ++------------ |
| M | shikaku/ | 82 | +----- |
| M | shikaku/ | 121 | ++------- |
@@ -10,6 +10,7 @@ import (
1010 "charm.land/lipgloss/v2"1111 "github.com/FelineStateMachine/puzzletea/game"1212 "github.com/FelineStateMachine/puzzletea/theme"13+ "github.com/charmbracelet/x/ansi"1314 )1415 1516 func TestValidateGridState(t *testing.T) {@@ -266,6 +267,27 @@ func TestGridViewUsesUniformRows(t *testing.T) {
266267 }267268 }268269 270+func TestGridViewOmitsBorderBetweenAdjacentEmptyCells(t *testing.T) {271+ m := Model{272+ width: 2,273+ height: 1,274+ grid: grid{{0, 0}},275+ provided: newProvidedMask(grid{{0, 0}}),276+ conflicts: validateGridState(grid{{0, 0}}).conflicts,277+ }278+279+ view := ansi.Strip(gridView(m))280+ lines := strings.Split(view, "\n")281+ if len(lines) < 2 {282+ t.Fatalf("expected at least 2 lines, got %d", len(lines))283+ }284+285+ content := []rune(lines[1])286+ if got := content[4]; got != ' ' {287+ t.Fatalf("separator between adjacent empty cells = %q, want space", got)288+ }289+}290+269291 func TestSolvedGapBackgroundUsesSolvedColor(t *testing.T) {270292 conflicts := validateGridState(grid{{2, 2}}).conflicts271293 m := Model{@@ -1,44 +1,19 @@
11 package fillomino22 3-import (4- "strings"5-6- "charm.land/lipgloss/v2"7- "github.com/FelineStateMachine/puzzletea/game"8-)3+import "github.com/FelineStateMachine/puzzletea/game"94 105 // screenToGrid converts terminal coordinates to fillomino grid coordinates.116 // Returns false when the click lands on a border row/column or outside the grid.127 func (m *Model) screenToGrid(screenX, screenY int) (col, row int, ok bool) {138 ox, oy := m.cachedGridOrigin()14- lx := screenX - ox15- ly := screenY - oy16- if lx < 0 || ly < 0 {17- return 0, 0, false18- }19-20- maxX := 021- if m.width > 0 {22- maxX = (m.width-1)*(cellWidth+1) + (cellWidth - 1)23- }24- maxY := 025- if m.height > 0 {26- maxY = (m.height - 1) * 227- }28- if lx > maxX || ly > maxY {29- return 0, 0, false30- }31-32- col = lx / (cellWidth + 1)33- row = ly / 234- if lx%(cellWidth+1) >= cellWidth || ly%2 != 0 {35- return 0, 0, false36- }37- if col < 0 || col >= m.width || row < 0 || row >= m.height {38- return 0, 0, false39- }40-41- return col, row, true9+ return game.DynamicGridScreenToCell(10+ game.DynamicGridMetrics{Width: m.width, Height: m.height, CellWidth: cellWidth},11+ ox,12+ oy,13+ screenX,14+ screenY,15+ false,16+ )4217 }4318 4419 func (m *Model) cachedGridOrigin() (x, y int) {@@ -52,31 +27,7 @@ func (m *Model) cachedGridOrigin() (x, y int) {
5227 }5328 5429 func (m *Model) gridOrigin() (x, y int) {55- gameView := m.View()56- viewWidth := lipgloss.Width(gameView)57- viewHeight := lipgloss.Height(gameView)58-59- if viewWidth > m.termWidth {60- viewWidth = m.termWidth61- }62- if viewHeight > m.termHeight {63- viewHeight = m.termHeight64- }65-66- centerX := max((m.termWidth-viewWidth)/2, 0)67- centerY := max((m.termHeight-viewHeight)/2, 0)68-6930 title := game.TitleBarView("Fillomino", m.modeTitle, m.solved)70- titleHeight := strings.Count(title, "\n") + 171-7231 grid := gridView(*m)73- gridWidth := lipgloss.Width(grid)74- gridPadLeft := max((viewWidth-gridWidth)/2, 0)75-76- const borderLeft = 177- const borderTop = 178-79- x = centerX + gridPadLeft + borderLeft80- y = centerY + titleHeight + borderTop81- return x, y32+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)8233 }@@ -3,14 +3,19 @@ package fillomino
33 import (44 "image/color"55 "strconv"6- "strings"76 87 "charm.land/lipgloss/v2"98 "github.com/FelineStateMachine/puzzletea/game"109 "github.com/FelineStateMachine/puzzletea/theme"1110 )1211 13-const cellWidth = 312+const cellWidth = game.DynamicGridCellWidth13+14+type renderGridState struct {15+ zones [][]int16+ activeZone int17+ completed map[int]color.Color18+}1419 1520 func cellBaseStyle() lipgloss.Style {1621 p := theme.Current()@@ -78,80 +83,110 @@ func conflictedCursorStyle() lipgloss.Style {
7883 }7984 8085 func gridView(m Model) string {81- highlight := regionCellsAt(m.grid, point{x: m.cursor.X, y: m.cursor.Y})82- completed := completedRegionBackgrounds(m.grid, m.conflicts)83- regionSet := make(map[point]struct{}, len(highlight))84- for _, cell := range highlight {85- regionSet[cell] = struct{}{}86+ renderState := buildRenderGridState(m)87+88+ return game.RenderDynamicGrid(game.DynamicGridSpec{89+ Width: m.width,90+ Height: m.height,91+ Solved: m.solved,92+ Cell: func(x, y int) string {93+ zone := renderState.zones[y][x]94+ completedBG := renderState.completed[zone]95+ return cellView(96+ m.grid[y][x],97+ m.provided[y][x],98+ x == m.cursor.X && y == m.cursor.Y,99+ y == m.cursor.Y,100+ x == m.cursor.X,101+ renderState.activeZone >= 0 && zone == renderState.activeZone,102+ m.solved,103+ m.conflicts[y][x],104+ completedBG,105+ )106+ },107+ ZoneAt: func(x, y int) int {108+ return renderState.zones[y][x]109+ },110+ ZoneFill: func(zone int) color.Color {111+ p := theme.Current()112+ switch {113+ case m.solved:114+ return p.SuccessBG115+ case renderState.completed[zone] != nil:116+ return renderState.completed[zone]117+ case renderState.activeZone >= 0 && zone == renderState.activeZone:118+ return p.HighlightBG119+ default:120+ return nil121+ }122+ },123+ BridgeFill: func(bridge game.DynamicGridBridge) color.Color {124+ for i := 0; i < bridge.Count; i++ {125+ cell := bridge.Cells[i]126+ if m.conflicts[cell.Y][cell.X] {127+ return game.ConflictBG()128+ }129+ }130+ return nil131+ },132+ })133+}134+135+func buildRenderGridState(m Model) renderGridState {136+ height := len(m.grid)137+ if height == 0 {138+ return renderGridState{}139+ }140+ width := len(m.grid[0])141+ zones := make([][]int, height)142+ for y := range height {143+ zones[y] = make([]int, width)86144 }87145 88- rows := make([]string, 0, m.height*2+1)89- rows = append(rows, boundaryRow(m, 0, regionSet, completed))90- for y := range m.height {91- rows = append(rows, contentRow(m, y, regionSet, completed))92- rows = append(rows, boundaryRow(m, y+1, regionSet, completed))146+ visited := make([][]bool, height)147+ for y := range height {148+ visited[y] = make([]bool, width)93149 }94- return lipgloss.JoinVertical(lipgloss.Left, rows...)95-}96150 97-func contentRow(m Model, y int, regionSet map[point]struct{}, completed map[point]color.Color) string {98- var b strings.Builder99- for x := range m.width {100- b.WriteString(renderBorderChar(verticalEdge(m.grid, x, y), m.solved, verticalGapBackground(m, regionSet, completed, x, y)))101- _, inRegion := regionSet[point{x: x, y: y}]102- b.WriteString(cellView(103- m.grid[y][x],104- m.provided[y][x],105- x == m.cursor.X && y == m.cursor.Y,106- y == m.cursor.Y,107- x == m.cursor.X,108- inRegion,109- m.solved,110- m.conflicts[y][x],111- completed[point{x: x, y: y}],112- ))113- }114- b.WriteString(renderBorderChar(verticalEdge(m.grid, m.width, y), m.solved, verticalGapBackground(m, regionSet, completed, m.width, y)))115- return b.String()116-}151+ palette := theme.Current()152+ colors := palette.ThemeColors()153+ completed := make(map[int]color.Color)154+ nextZone := 0155+ activeZone := -1156+ emptyZone := nextZone157+ nextZone++117158 118-func boundaryRow(m Model, y int, regionSet map[point]struct{}, completed map[point]color.Color) string {119- var b strings.Builder120- for x := 0; x <= m.width; x++ {121- b.WriteString(renderBorderChar(junctionRune(m.grid, x, y), m.solved, junctionGapBackground(m, regionSet, completed, x, y)))122- if x == m.width {123- continue124- }159+ for y := range height {160+ for x := range width {161+ if m.grid[y][x] == 0 {162+ zones[y][x] = emptyZone163+ continue164+ }165+ if visited[y][x] {166+ continue167+ }125168 126- ch := ' '127- if horizontalEdge(m.grid, x, y) {128- ch = '─'169+ comp := buildComponent(m.grid, point{x: x, y: y}, visited)170+ zone := nextZone171+ nextZone++172+ for _, cell := range comp.cells {173+ zones[cell.y][cell.x] = zone174+ }175+ if len(colors) > 0 && len(comp.cells) == comp.value && !componentHasConflict(comp, m.conflicts) {176+ completed[zone] = completedRegionColor(comp, colors, palette.Surface)177+ }129178 }130- b.WriteString(renderBorderSegment(ch, m.solved, horizontalGapBackground(m, regionSet, completed, x, y)))131179 }132- return b.String()133-}134180 135-func renderBorderChar(ch rune, solved bool, bg color.Color) string {136- p := theme.Current()137- fg := p.Border138- if solved {139- fg = p.SuccessBorder140- }141- if bg == nil {142- bg = p.BG143- if solved {144- bg = p.SuccessBG145- }181+ if m.cursor.Y >= 0 && m.cursor.Y < height && m.cursor.X >= 0 && m.cursor.X < width && m.grid[m.cursor.Y][m.cursor.X] != 0 {182+ activeZone = zones[m.cursor.Y][m.cursor.X]146183 }147- return lipgloss.NewStyle().148- Foreground(fg).149- Background(bg).150- Render(string(ch))151-}152184 153-func renderBorderSegment(ch rune, solved bool, bg color.Color) string {154- return strings.Repeat(renderBorderChar(ch, solved, bg), cellWidth)185+ return renderGridState{186+ zones: zones,187+ activeZone: activeZone,188+ completed: completed,189+ }155190 }156191 157192 func horizontalEdge(g grid, x, y int) bool {@@ -0,0 +1,374 @@
1+package game2+3+import (4+ "image"5+ "image/color"6+ "strings"7+8+ "charm.land/lipgloss/v2"9+)10+11+const DynamicGridCellWidth = 312+13+const (14+ dynamicGridCellStepX = DynamicGridCellWidth + 115+ dynamicGridCellStepY = 216+)17+18+type DynamicGridMetrics struct {19+ Width int20+ Height int21+ CellWidth int22+}23+24+type DynamicGridBridgeKind int25+26+const (27+ DynamicGridBridgeVertical DynamicGridBridgeKind = iota28+ DynamicGridBridgeHorizontal29+ DynamicGridBridgeJunction30+)31+32+type DynamicGridBridge struct {33+ Kind DynamicGridBridgeKind34+ X int35+ Y int36+ Cells [4]image.Point37+ Count int38+ Zone int39+ Uniform bool40+}41+42+type DynamicGridSpec struct {43+ Width int44+ Height int45+ Solved bool46+ Colors *GridBorderColors47+ Cell func(x, y int) string48+ ZoneAt func(x, y int) int49+ ZoneFill func(zone int) color.Color50+ BridgeFill func(bridge DynamicGridBridge) color.Color51+}52+53+func RenderDynamicGrid(spec DynamicGridSpec) string {54+ rows := make([]string, 0, spec.Height*dynamicGridCellStepY+1)55+ rows = append(rows, dynamicGridBoundaryRow(spec, 0))56+ for y := range spec.Height {57+ rows = append(rows, dynamicGridContentRow(spec, y))58+ rows = append(rows, dynamicGridBoundaryRow(spec, y+1))59+ }60+ return lipgloss.JoinVertical(lipgloss.Left, rows...)61+}62+63+func DynamicGridOrigin(termW, termH int, fullView, title, grid string) (x, y int) {64+ viewWidth := lipgloss.Width(fullView)65+ viewHeight := lipgloss.Height(fullView)66+ if viewWidth > termW {67+ viewWidth = termW68+ }69+ if viewHeight > termH {70+ viewHeight = termH71+ }72+73+ centerX := max((termW-viewWidth)/2, 0)74+ centerY := max((termH-viewHeight)/2, 0)75+76+ titleHeight := strings.Count(title, "\n") + 177+ gridWidth := lipgloss.Width(grid)78+ gridPadLeft := max((viewWidth-gridWidth)/2, 0)79+80+ return centerX + 1 + gridPadLeft, centerY + 1 + titleHeight81+}82+83+func DynamicGridScreenToCell(84+ metrics DynamicGridMetrics,85+ originX, originY, screenX, screenY int,86+ includeSeparators bool,87+) (col, row int, ok bool) {88+ if metrics.Width <= 0 || metrics.Height <= 0 {89+ return 0, 0, false90+ }91+92+ cellWidth := metrics.CellWidth93+ if cellWidth <= 0 {94+ cellWidth = DynamicGridCellWidth95+ }96+97+ lx := screenX - originX98+ ly := screenY - originY99+ if lx < 0 || ly < 0 {100+ return 0, 0, false101+ }102+103+ maxX := (metrics.Width-1)*(cellWidth+1) + (cellWidth - 1)104+ maxY := (metrics.Height - 1) * dynamicGridCellStepY105+ if lx > maxX || ly > maxY {106+ return 0, 0, false107+ }108+109+ if includeSeparators {110+ col = min((lx+cellWidth/2)/(cellWidth+1), metrics.Width-1)111+ row = min((ly+1)/dynamicGridCellStepY, metrics.Height-1)112+ } else {113+ col = lx / (cellWidth + 1)114+ row = ly / dynamicGridCellStepY115+ if lx%(cellWidth+1) >= cellWidth || ly%dynamicGridCellStepY != 0 {116+ return 0, 0, false117+ }118+ }119+120+ if col < 0 || col >= metrics.Width || row < 0 || row >= metrics.Height {121+ return 0, 0, false122+ }123+ return col, row, true124+}125+126+func dynamicGridContentRow(spec DynamicGridSpec, y int) string {127+ var b strings.Builder128+ for x := range spec.Width {129+ bridge := dynamicGridVerticalBridge(spec, x, y)130+ b.WriteString(dynamicGridRenderBorderChar(131+ verticalEdgeRune(dynamicGridHasVerticalEdge(spec, x, y)),132+ dynamicGridColors(spec),133+ spec.Solved,134+ dynamicGridBridgeBackground(spec, bridge),135+ ))136+ if spec.Cell != nil {137+ b.WriteString(spec.Cell(x, y))138+ }139+ }140+141+ bridge := dynamicGridVerticalBridge(spec, spec.Width, y)142+ b.WriteString(dynamicGridRenderBorderChar(143+ verticalEdgeRune(dynamicGridHasVerticalEdge(spec, spec.Width, y)),144+ dynamicGridColors(spec),145+ spec.Solved,146+ dynamicGridBridgeBackground(spec, bridge),147+ ))148+ return b.String()149+}150+151+func dynamicGridBoundaryRow(spec DynamicGridSpec, y int) string {152+ var b strings.Builder153+ for x := 0; x <= spec.Width; x++ {154+ runeValue := dynamicGridJunctionRune(spec, x, y)155+ b.WriteString(dynamicGridRenderBorderChar(156+ runeValue,157+ dynamicGridColors(spec),158+ spec.Solved,159+ dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),160+ ))161+ if x == spec.Width {162+ continue163+ }164+165+ ch := ' '166+ if dynamicGridHasHorizontalEdge(spec, x, y) {167+ ch = '─'168+ }169+ b.WriteString(strings.Repeat(170+ dynamicGridRenderBorderChar(171+ ch,172+ dynamicGridColors(spec),173+ spec.Solved,174+ dynamicGridBridgeBackground(spec, dynamicGridHorizontalBridge(spec, x, y)),175+ ),176+ DynamicGridCellWidth,177+ ))178+ }179+ return b.String()180+}181+182+func dynamicGridColors(spec DynamicGridSpec) GridBorderColors {183+ if spec.Colors != nil {184+ return *spec.Colors185+ }186+ return DefaultBorderColors()187+}188+189+func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool, bg color.Color) string {190+ fg := colors.BorderFG191+ if solved {192+ fg = colors.SolvedBorderFG193+ }194+ if bg == nil {195+ bg = colors.BackgroundBG196+ if solved {197+ bg = colors.SolvedBG198+ }199+ }200+ return lipgloss.NewStyle().201+ Foreground(fg).202+ Background(bg).203+ Render(string(ch))204+}205+206+func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {207+ switch {208+ case y <= 0, y >= spec.Height:209+ return true210+ default:211+ return dynamicGridZoneAt(spec, x, y-1) != dynamicGridZoneAt(spec, x, y)212+ }213+}214+215+func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {216+ switch {217+ case x <= 0, x >= spec.Width:218+ return true219+ default:220+ return dynamicGridZoneAt(spec, x-1, y) != dynamicGridZoneAt(spec, x, y)221+ }222+}223+224+func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {225+ north := y > 0 && dynamicGridHasVerticalEdge(spec, x, y-1)226+ south := y < spec.Height && dynamicGridHasVerticalEdge(spec, x, y)227+ west := x > 0 && dynamicGridHasHorizontalEdge(spec, x-1, y)228+ east := x < spec.Width && dynamicGridHasHorizontalEdge(spec, x, y)229+230+ switch {231+ case north && south && west && east:232+ return '┼'233+ case north && south && west:234+ return '┤'235+ case north && south && east:236+ return '├'237+ case west && east && north:238+ return '┴'239+ case west && east && south:240+ return '┬'241+ case south && east:242+ return '┌'243+ case south && west:244+ return '┐'245+ case north && east:246+ return '└'247+ case north && west:248+ return '┘'249+ case north || south:250+ return '│'251+ case west || east:252+ return '─'253+ default:254+ return ' '255+ }256+}257+258+func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {259+ if bridge.Count == 0 {260+ return nil261+ }262+ if spec.BridgeFill != nil {263+ if bg := spec.BridgeFill(bridge); bg != nil {264+ return bg265+ }266+ }267+ if bridge.Uniform && spec.ZoneFill != nil {268+ return spec.ZoneFill(bridge.Zone)269+ }270+ return nil271+}272+273+func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {274+ if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {275+ return DynamicGridBridge{276+ Kind: DynamicGridBridgeVertical,277+ X: x,278+ Y: y,279+ }280+ }281+282+ return dynamicGridBridge(283+ spec,284+ DynamicGridBridgeVertical,285+ x,286+ y,287+ image.Pt(x-1, y),288+ image.Pt(x, y),289+ )290+}291+292+func dynamicGridHorizontalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {293+ if dynamicGridHasHorizontalEdge(spec, x, y) || y <= 0 || y >= spec.Height {294+ return DynamicGridBridge{295+ Kind: DynamicGridBridgeHorizontal,296+ X: x,297+ Y: y,298+ }299+ }300+301+ return dynamicGridBridge(302+ spec,303+ DynamicGridBridgeHorizontal,304+ x,305+ y,306+ image.Pt(x, y-1),307+ image.Pt(x, y),308+ )309+}310+311+func dynamicGridJunctionBridge(spec DynamicGridSpec, x, y int, runeValue rune) DynamicGridBridge {312+ if runeValue != ' ' {313+ return DynamicGridBridge{314+ Kind: DynamicGridBridgeJunction,315+ X: x,316+ Y: y,317+ }318+ }319+ if x <= 0 || y <= 0 || x >= spec.Width || y >= spec.Height {320+ return DynamicGridBridge{321+ Kind: DynamicGridBridgeJunction,322+ X: x,323+ Y: y,324+ }325+ }326+327+ return dynamicGridBridge(328+ spec,329+ DynamicGridBridgeJunction,330+ x,331+ y,332+ image.Pt(x-1, y-1),333+ image.Pt(x, y-1),334+ image.Pt(x-1, y),335+ image.Pt(x, y),336+ )337+}338+339+func dynamicGridBridge(spec DynamicGridSpec, kind DynamicGridBridgeKind, x, y int, cells ...image.Point) DynamicGridBridge {340+ bridge := DynamicGridBridge{341+ Kind: kind,342+ X: x,343+ Y: y,344+ Count: len(cells),345+ Uniform: len(cells) > 0,346+ }347+348+ for i := range cells {349+ bridge.Cells[i] = cells[i]350+ zone := dynamicGridZoneAt(spec, cells[i].X, cells[i].Y)351+ if i == 0 {352+ bridge.Zone = zone353+ continue354+ }355+ if zone != bridge.Zone {356+ bridge.Uniform = false357+ }358+ }359+ return bridge360+}361+362+func dynamicGridZoneAt(spec DynamicGridSpec, x, y int) int {363+ if spec.ZoneAt == nil {364+ return 0365+ }366+ return spec.ZoneAt(x, y)367+}368+369+func verticalEdgeRune(hasEdge bool) rune {370+ if hasEdge {371+ return '│'372+ }373+ return ' '374+}@@ -0,0 +1,153 @@
1+package game2+3+import (4+ "strings"5+ "testing"6+7+ "charm.land/lipgloss/v2"8+ "github.com/FelineStateMachine/puzzletea/theme"9+ "github.com/charmbracelet/x/ansi"10+)11+12+func TestRenderDynamicGridCenterJunctionRune(t *testing.T) {13+ tests := []struct {14+ name string15+ zones [][]int16+ wantRune rune17+ }{18+ {19+ name: "open interior",20+ zones: [][]int{{0, 0}, {0, 0}},21+ wantRune: ' ',22+ },23+ {24+ name: "vertical wall",25+ zones: [][]int{{0, 1}, {0, 1}},26+ wantRune: '│',27+ },28+ {29+ name: "cross",30+ zones: [][]int{{0, 1}, {2, 3}},31+ wantRune: '┼',32+ },33+ }34+35+ for _, tt := range tests {36+ t.Run(tt.name, func(t *testing.T) {37+ view := RenderDynamicGrid(DynamicGridSpec{38+ Width: 2,39+ Height: 2,40+ Cell: func(_, _ int) string {41+ return " "42+ },43+ ZoneAt: func(x, y int) int {44+ return tt.zones[y][x]45+ },46+ })47+48+ lines := strings.Split(ansi.Strip(view), "\n")49+ got := []rune(lines[2])[4]50+ if got != tt.wantRune {51+ t.Fatalf("center junction = %q, want %q", got, tt.wantRune)52+ }53+ })54+ }55+}56+57+func TestRenderDynamicGridUsesUniformRows(t *testing.T) {58+ view := RenderDynamicGrid(DynamicGridSpec{59+ Width: 3,60+ Height: 3,61+ Cell: func(_, _ int) string {62+ return " "63+ },64+ ZoneAt: func(x, y int) int {65+ return y*10 + x66+ },67+ })68+69+ lines := strings.Split(view, "\n")70+ if got, want := len(lines), 7; got != want {71+ t.Fatalf("line count = %d, want %d", got, want)72+ }73+74+ wantWidth := lipgloss.Width(lines[0])75+ for i, line := range lines {76+ if width := lipgloss.Width(line); width != wantWidth {77+ t.Fatalf("line %d width = %d, want %d", i, width, wantWidth)78+ }79+ }80+}81+82+func TestDynamicGridRenderBorderCharSolvedDefaults(t *testing.T) {83+ got := dynamicGridRenderBorderChar('│', DefaultBorderColors(), true, nil)84+ want := lipgloss.NewStyle().85+ Foreground(theme.Current().SuccessBorder).86+ Background(theme.Current().SuccessBG).87+ Render("│")88+ if got != want {89+ t.Fatal("expected solved border char to use solved colors")90+ }91+}92+93+func TestDynamicGridScreenToCell(t *testing.T) {94+ metrics := DynamicGridMetrics{Width: 3, Height: 3, CellWidth: DynamicGridCellWidth}95+ originX, originY := 10, 2096+97+ tests := []struct {98+ name string99+ screenX int100+ screenY int101+ includeSeparators bool102+ wantCol int103+ wantRow int104+ wantOK bool105+ }{106+ {"cell origin", originX, originY, false, 0, 0, true},107+ {"next cell", originX + DynamicGridCellWidth + 1, originY, false, 1, 0, true},108+ {"separator rejected", originX + DynamicGridCellWidth, originY, false, 0, 0, false},109+ {"separator snaps during drag", originX + DynamicGridCellWidth, originY, true, 1, 0, true},110+ {"outside right", originX + 3*(DynamicGridCellWidth+1), originY, false, 0, 0, false},111+ }112+113+ for _, tt := range tests {114+ t.Run(tt.name, func(t *testing.T) {115+ col, row, ok := DynamicGridScreenToCell(116+ metrics,117+ originX,118+ originY,119+ tt.screenX,120+ tt.screenY,121+ tt.includeSeparators,122+ )123+ if ok != tt.wantOK {124+ t.Fatalf("ok = %v, want %v", ok, tt.wantOK)125+ }126+ if ok && (col != tt.wantCol || row != tt.wantRow) {127+ t.Fatalf("cell = (%d,%d), want (%d,%d)", col, row, tt.wantCol, tt.wantRow)128+ }129+ })130+ }131+}132+133+func TestDynamicGridOriginStableAcrossSecondaryRows(t *testing.T) {134+ grid := RenderDynamicGrid(DynamicGridSpec{135+ Width: 3,136+ Height: 2,137+ Cell: func(_, _ int) string {138+ return " "139+ },140+ ZoneAt: func(_, _ int) int {141+ return 0142+ },143+ })144+ title := TitleBarView("Test", "Mode", false)145+ shortView := ComposeGameViewRows(title, grid, StaticRow("1-9: place"))146+ longView := ComposeGameViewRows(title, grid, StableRow("1-9: place", strings.Repeat("long help ", 6)))147+148+ shortX, shortY := DynamicGridOrigin(120, 40, shortView, title, grid)149+ longX, longY := DynamicGridOrigin(120, 40, longView, title, grid)150+ if shortX != longX || shortY != longY {151+ t.Fatalf("origin changed from (%d,%d) to (%d,%d)", shortX, shortY, longX, longY)152+ }153+}@@ -3,14 +3,13 @@ package rippleeffect
33 import (44 "image/color"55 "strconv"6- "strings"76 87 "charm.land/lipgloss/v2"98 "github.com/FelineStateMachine/puzzletea/game"109 "github.com/FelineStateMachine/puzzletea/theme"1110 )1211 13-const cellWidth = 312+const cellWidth = game.DynamicGridCellWidth1413 1514 type visualKind int1615 @@ -47,43 +46,23 @@ func chooseVisualKind(cursor, solved, conflict, completed, cageHighlight, crossh
4746 }4847 4948 func gridView(m Model) string {50- rows := make([]string, 0, m.height*2+1)5149 activeCage := m.geo.cageGrid[m.cursor.Y][m.cursor.X]5250 completed := completedCageBackgrounds(m)5351 bridgeBG := bridgeBackgrounds(m, completed)54- rows = append(rows, boundaryRow(m, 0, bridgeBG))55- for y := range m.height {56- rows = append(rows, contentRow(m, y, activeCage, bridgeBG))57- rows = append(rows, boundaryRow(m, y+1, bridgeBG))58- }59- return lipgloss.JoinVertical(lipgloss.Left, rows...)60-}61-62-func contentRow(m Model, y, activeCage int, completed map[int]color.Color) string {63- var b strings.Builder64- for x := range m.width {65- b.WriteString(renderBorderChar(m, verticalEdge(m.geo, x, y), m.solved, verticalGapBackground(m.geo, completed, x, y)))66- b.WriteString(cellView(m, x, y, activeCage, completed[m.geo.cageGrid[y][x]]))67- }68- b.WriteString(renderBorderChar(m, verticalEdge(m.geo, m.width, y), m.solved, verticalGapBackground(m.geo, completed, m.width, y)))69- return b.String()70-}71-72-func boundaryRow(m Model, y int, completed map[int]color.Color) string {73- var b strings.Builder74- for x := 0; x <= m.width; x++ {75- b.WriteString(renderBorderChar(m, junctionRune(m.geo, x, y), m.solved, junctionGapBackground(m.geo, completed, x, y)))76- if x == m.width {77- continue78- }79-80- ch := ' '81- if horizontalEdge(m.geo, x, y) {82- ch = '─'83- }84- b.WriteString(renderBorderSegment(m, ch, m.solved, horizontalGapBackground(m.geo, completed, x, y)))85- }86- return b.String()52+ return game.RenderDynamicGrid(game.DynamicGridSpec{53+ Width: m.width,54+ Height: m.height,55+ Solved: m.solved,56+ Cell: func(x, y int) string {57+ return cellView(m, x, y, activeCage, completed[m.geo.cageGrid[y][x]])58+ },59+ ZoneAt: func(x, y int) int {60+ return m.geo.cageGrid[y][x]61+ },62+ ZoneFill: func(zone int) color.Color {63+ return bridgeBG[zone]64+ },65+ })8766 }8867 8968 func cellView(m Model, x, y, activeCage int, completedBG color.Color) string {@@ -241,93 +220,8 @@ func completedCageColor(cage Cage, colors []color.Color, base color.Color) color
241220 return theme.Blend(base, colors[index], 0.52)242221 }243222 244-func renderBorderChar(m Model, ch rune, solved bool, bg color.Color) string {245- p := theme.Current()246- fg := p.Border247- if solved {248- fg = p.SuccessBorder249- }250- if bg == nil {251- bg = p.BG252- if solved {253- bg = p.SuccessBG254- }255- }256- return lipgloss.NewStyle().257- Foreground(fg).258- Background(bg).259- Render(string(ch))260-}261-262-func renderBorderSegment(m Model, ch rune, solved bool, bg color.Color) string {263- return strings.Repeat(renderBorderChar(m, ch, solved, bg), cellWidth)264-}265-266-func horizontalEdge(geo *geometry, x, y int) bool {267- switch {268- case y <= 0:269- return geo.boundaries[0][x].has(boundaryTop)270- case y >= geo.height:271- return geo.boundaries[geo.height-1][x].has(boundaryBottom)272- default:273- return geo.boundaries[y][x].has(boundaryTop)274- }275-}276-277-func verticalEdge(geo *geometry, x, y int) rune {278- if hasVerticalEdge(geo, x, y) {279- return '│'280- }281- return ' '282-}283-284-func hasVerticalEdge(geo *geometry, x, y int) bool {285- switch {286- case x <= 0:287- return geo.boundaries[y][0].has(boundaryLeft)288- case x >= geo.width:289- return geo.boundaries[y][geo.width-1].has(boundaryRight)290- default:291- return geo.boundaries[y][x].has(boundaryLeft)292- }293-}294-295-func junctionRune(geo *geometry, x, y int) rune {296- north := y > 0 && hasVerticalEdge(geo, x, y-1)297- south := y < geo.height && hasVerticalEdge(geo, x, y)298- west := x > 0 && horizontalEdge(geo, x-1, y)299- east := x < geo.width && horizontalEdge(geo, x, y)300-301- switch {302- case north && south && west && east:303- return '┼'304- case north && south && west:305- return '┤'306- case north && south && east:307- return '├'308- case west && east && north:309- return '┴'310- case west && east && south:311- return '┬'312- case south && east:313- return '┌'314- case south && west:315- return '┐'316- case north && east:317- return '└'318- case north && west:319- return '┘'320- case north || south:321- return '│'322- case west || east:323- return '─'324- default:325- return ' '326- }327-}328-329-func verticalGapBackground(geo *geometry, completed map[int]color.Color, x, y int) color.Color {330- if hasVerticalEdge(geo, x, y) || x <= 0 || x >= geo.width {223+func verticalGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y int) color.Color {224+ if geo == nil || x <= 0 || x >= geo.width || y < 0 || y >= geo.height {331225 return nil332226 }333227 left := geo.cageGrid[y][x-1]@@ -335,11 +229,11 @@ func verticalGapBackground(geo *geometry, completed map[int]color.Color, x, y in
335229 if left != right {336230 return nil337231 }338- return completed[left]232+ return backgrounds[left]339233 }340234 341-func horizontalGapBackground(geo *geometry, completed map[int]color.Color, x, y int) color.Color {342- if horizontalEdge(geo, x, y) || y <= 0 || y >= geo.height {235+func horizontalGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y int) color.Color {236+ if geo == nil || y <= 0 || y >= geo.height || x < 0 || x >= geo.width {343237 return nil344238 }345239 top := geo.cageGrid[y-1][x]@@ -347,38 +241,19 @@ func horizontalGapBackground(geo *geometry, completed map[int]color.Color, x, y
347241 if top != bottom {348242 return nil349243 }350- return completed[top]244+ return backgrounds[top]351245 }352246 353-func junctionGapBackground(geo *geometry, completed map[int]color.Color, x, y int) color.Color {354- if junctionRune(geo, x, y) != ' ' {247+func junctionGapBackground(geo *geometry, backgrounds map[int]color.Color, x, y int) color.Color {248+ if geo == nil || x <= 0 || x >= geo.width || y <= 0 || y >= geo.height {355249 return nil356250 }357251 358- cages := make([]int, 0, 4)359- if x > 0 && y > 0 {360- cages = append(cages, geo.cageGrid[y-1][x-1])361- }362- if x < geo.width && y > 0 {363- cages = append(cages, geo.cageGrid[y-1][x])364- }365- if x > 0 && y < geo.height {366- cages = append(cages, geo.cageGrid[y][x-1])367- }368- if x < geo.width && y < geo.height {369- cages = append(cages, geo.cageGrid[y][x])370- }371- if len(cages) == 0 {252+ cageID := geo.cageGrid[y-1][x-1]253+ if geo.cageGrid[y-1][x] != cageID || geo.cageGrid[y][x-1] != cageID || geo.cageGrid[y][x] != cageID {372254 return nil373255 }374-375- cageID := cages[0]376- for _, id := range cages[1:] {377- if id != cageID {378- return nil379- }380- }381- return completed[cageID]256+ return backgrounds[cageID]382257 }383258 384259 func statusBarView(showFullHelp bool) string {@@ -1,11 +1,6 @@
11 package shikaku22 3-import (4- "strings"5-6- "charm.land/lipgloss/v2"7- "github.com/FelineStateMachine/puzzletea/game"8-)3+import "github.com/FelineStateMachine/puzzletea/game"94 105 // screenToGrid converts terminal screen coordinates to grid cell coordinates.116 // Returns (col, row, ok) where ok is false if the click landed outside the grid.@@ -19,40 +14,18 @@ func (m *Model) screenToGridDrag(screenX, screenY int) (col, row int, ok bool) {
1914 2015 func (m *Model) screenToGridAt(screenX, screenY int, includeSeparators bool) (col, row int, ok bool) {2116 ox, oy := m.cachedGridOrigin()22- lx := screenX - ox23- ly := screenY - oy24- if lx < 0 || ly < 0 {25- return 0, 0, false26- }27-28- maxX := 029- if m.puzzle.Width > 0 {30- maxX = (m.puzzle.Width-1)*(cellWidth+1) + (cellWidth - 1)31- }32- maxY := 033- if m.puzzle.Height > 0 {34- maxY = (m.puzzle.Height - 1) * 235- }36- if lx > maxX || ly > maxY {37- return 0, 0, false38- }39-40- if includeSeparators {41- col = min((lx+cellWidth/2)/(cellWidth+1), m.puzzle.Width-1)42- row = min((ly+1)/2, m.puzzle.Height-1)43- } else {44- col = lx / (cellWidth + 1)45- row = ly / 246- if lx%(cellWidth+1) >= cellWidth || ly%2 != 0 {47- return 0, 0, false48- }49- }50-51- if col < 0 || col >= m.puzzle.Width || row < 0 || row >= m.puzzle.Height {52- return 0, 0, false53- }54-55- return col, row, true17+ return game.DynamicGridScreenToCell(18+ game.DynamicGridMetrics{19+ Width: m.puzzle.Width,20+ Height: m.puzzle.Height,21+ CellWidth: cellWidth,22+ },23+ ox,24+ oy,25+ screenX,26+ screenY,27+ includeSeparators,28+ )5629 }5730 5831 // cachedGridOrigin returns the screen position of the top-left corner of@@ -86,35 +59,8 @@ func (m *Model) cachedGridOrigin() (x, y int) {
8659 // info8760 // status8861 func (m *Model) gridOrigin() (x, y int) {89- gameView := m.View()90- viewWidth := lipgloss.Width(gameView)91- viewHeight := lipgloss.Height(gameView)92-93- if viewWidth > m.termWidth {94- viewWidth = m.termWidth95- }96-97- // Centering offset applied by the root's lipgloss.Place().98- centerX := max((m.termWidth-viewWidth)/2, 0)99- centerY := max((m.termHeight-viewHeight)/2, 0)100-101- // Measure the title height.10262 solved := m.puzzle.IsSolved()10363 title := game.TitleBarView("Shikaku", m.modeTitle, solved)104- titleHeight := strings.Count(title, "\n") + 1105-106- // The grid is rendered directly inside the game view. Measure its width to107- // compute horizontal padding from JoinVertical(Center, ...).10864 grid := gridView(*m, solved)109- gridOuterWidth := lipgloss.Width(grid)110- gridPadLeft := max((viewWidth-gridOuterWidth)/2, 0)111-112- // The dynamic grid includes an outer wall, so the first cell content starts113- // one column right and one row below the grid's top-left corner.114- const borderLeft = 1115- const borderTop = 1116-117- x = centerX + gridPadLeft + borderLeft118- y = centerY + titleHeight + borderTop119- return x, y65+ return game.DynamicGridOrigin(m.termWidth, m.termHeight, m.View(), title, grid)12066 }@@ -11,7 +11,7 @@ import (
1111 )1212 1313 const (14- cellWidth = 314+ cellWidth = game.DynamicGridCellWidth1515 previewRegionID = -21616 )1717 @@ -19,77 +19,20 @@ func rectColors() []color.Color { return theme.Current().ThemeColors() }
1919 2020 func gridView(m Model, solved bool) string {2121 preview, previewClue, previewValid := activePreview(m)22- rows := make([]string, 0, m.puzzle.Height*2+1)23- rows = append(rows, boundaryRow(m, 0, preview, previewClue, previewValid, solved))24- for y := range m.puzzle.Height {25- rows = append(rows, contentRow(m, y, preview, previewClue, previewValid, solved))26- rows = append(rows, boundaryRow(m, y+1, preview, previewClue, previewValid, solved))27- }28- return lipgloss.JoinVertical(lipgloss.Left, rows...)29-}30-31-func activePreview(m Model) (*Rectangle, *Clue, bool) {32- var preview *Rectangle33- var previewClue *Clue34-35- if m.mousePreview != nil {36- preview = m.mousePreview37- clues := m.puzzle.CluesInRect(*preview)38- if len(clues) == 1 {39- previewClue = clues[0]40- }41- } else if m.selectedClue != nil {42- previewClue = m.puzzle.FindClueByID(*m.selectedClue)43- if previewClue != nil {44- r := m.expansion.rect(previewClue)45- preview = &r46- }47- }48-49- return preview, previewClue, previewClue != nil && m.puzzle.ValidRectangleForClue(*preview, previewClue.ID)50-}51-52-func contentRow(m Model, y int, preview *Rectangle, previewClue *Clue, previewValid, solved bool) string {53- var b strings.Builder54- for x := range m.puzzle.Width {55- b.WriteString(renderBorderChar(56- verticalEdge(m, preview, x, y),57- solved,58- verticalGapBackground(m, preview, previewClue, previewValid, x, y),59- ))60- b.WriteString(cellView(m, x, y, preview, previewClue, previewValid, solved))61- }62- b.WriteString(renderBorderChar(63- verticalEdge(m, preview, m.puzzle.Width, y),64- solved,65- verticalGapBackground(m, preview, previewClue, previewValid, m.puzzle.Width, y),66- ))67- return b.String()68-}69-70-func boundaryRow(m Model, y int, preview *Rectangle, previewClue *Clue, previewValid, solved bool) string {71- var b strings.Builder72- for x := 0; x <= m.puzzle.Width; x++ {73- b.WriteString(renderBorderChar(74- junctionRune(m, preview, x, y),75- solved,76- junctionGapBackground(m, preview, previewClue, previewValid, x, y),77- ))78- if x == m.puzzle.Width {79- continue80- }81-82- ch := ' '83- if horizontalEdge(m, preview, x, y) {84- ch = '─'85- }86- b.WriteString(renderBorderSegment(87- ch,88- solved,89- horizontalGapBackground(m, preview, previewClue, previewValid, x, y),90- ))91- }92- return b.String()22+ return game.RenderDynamicGrid(game.DynamicGridSpec{23+ Width: m.puzzle.Width,24+ Height: m.puzzle.Height,25+ Solved: solved,26+ Cell: func(x, y int) string {27+ return cellView(m, x, y, preview, previewClue, previewValid, solved)28+ },29+ ZoneAt: func(x, y int) int {30+ return regionTokenAt(m, preview, x, y)31+ },32+ ZoneFill: func(zone int) color.Color {33+ return regionBackground(m, zone, previewClue, previewValid)34+ },35+ })9336 }9437 9538 func cellView(m Model, x, y int, preview *Rectangle, previewClue *Clue, previewValid, solved bool) string {@@ -152,27 +95,25 @@ func cellView(m Model, x, y int, preview *Rectangle, previewClue *Clue, previewV
15295 return style.Foreground(fg).Background(bg).Render(display)15396 }15497 155-func renderBorderChar(ch rune, solved bool, bg color.Color) string {156- p := theme.Current()157- fg := p.Border158- if solved {159- fg = p.SuccessBorder160- }161- if bg == nil {162- bg = p.BG163- if solved {164- bg = p.SuccessBG98+func activePreview(m Model) (*Rectangle, *Clue, bool) {99+ var preview *Rectangle100+ var previewClue *Clue101+102+ if m.mousePreview != nil {103+ preview = m.mousePreview104+ clues := m.puzzle.CluesInRect(*preview)105+ if len(clues) == 1 {106+ previewClue = clues[0]107+ }108+ } else if m.selectedClue != nil {109+ previewClue = m.puzzle.FindClueByID(*m.selectedClue)110+ if previewClue != nil {111+ r := m.expansion.rect(previewClue)112+ preview = &r165113 }166114 }167115 168- return lipgloss.NewStyle().169- Foreground(fg).170- Background(bg).171- Render(string(ch))172-}173-174-func renderBorderSegment(ch rune, solved bool, bg color.Color) string {175- return strings.Repeat(renderBorderChar(ch, solved, bg), cellWidth)116+ return preview, previewClue, previewClue != nil && m.puzzle.ValidRectangleForClue(*preview, previewClue.ID)176117 }177118 178119 func regionTokenAt(m Model, preview *Rectangle, x, y int) int {