clone

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

bug/architecture-test-trim-path
puzzletea / games / nonogram / style.go
style.go 8.0K
1package nonogram23import (4 "fmt"5 "image/color"6 "strings"78 "charm.land/lipgloss/v2"9 "github.com/FelineStateMachine/puzzletea/game"10 "github.com/FelineStateMachine/puzzletea/theme"11)1213const (14 cellWidth = game.DynamicGridCellWidth15 spacerEvery = 516)1718var renderRuneMap = map[rune]string{19 filledTile: " \u25a0 ",20 markedTile: " \u2715 ",21 emptyTile: " \u00b7 ",22}2324func filledStyle() lipgloss.Style {25 p := theme.Current()26 return lipgloss.NewStyle().27 Foreground(p.AccentText).28 Background(p.AccentBG)29}3031func markedStyle() lipgloss.Style {32 p := theme.Current()33 return lipgloss.NewStyle().34 Foreground(p.AccentSoft).35 Background(p.BG)36}3738func emptyStyle() lipgloss.Style {39 p := theme.Current()40 return lipgloss.NewStyle().41 Foreground(p.TextDim).42 Background(p.BG)43}4445func hintStyle() lipgloss.Style {46 return lipgloss.NewStyle().47 Foreground(theme.Current().Info)48}4950func hintSatisfiedStyle() lipgloss.Style {51 return lipgloss.NewStyle().52 Foreground(theme.Current().SuccessBorder)53}5455func renderStyleMap() map[rune]lipgloss.Style {56 return map[rune]lipgloss.Style{57 filledTile: filledStyle(),58 markedTile: markedStyle(),59 emptyTile: emptyStyle(),60 }61}6263// needsSpacer reports whether a separator should be inserted after index i64// in a dimension of size n (i.e. after every spacerEvery cells, except the last).65func needsSpacer(i, n int) bool {66 return n > spacerEvery && (i+1)%spacerEvery == 0 && i < n-167}6869func colHintView(c TomographyDefinition, height int, current ...TomographyDefinition) string {70 var hasCurrent bool71 var curr TomographyDefinition72 if len(current) > 0 {73 hasCurrent = true74 curr = current[0]75 }7677 hintCellStyle := lipgloss.NewStyle().Width(cellWidth)7879 n := len(c)80 var renderedCols []string81 for i, hints := range c {82 var colHints []string83 for range height - len(hints) {84 pad := hintCellStyle.Render(" ")85 colHints = append(colHints, pad)86 }8788 satisfied := hasCurrent && i < len(curr) && intSliceEqual(hints, curr[i])8990 for _, hint := range hints {91 style := hintStyle()92 if satisfied {93 style = hintSatisfiedStyle()94 }95 hintCell := style.Width(cellWidth).96 Align(lipgloss.Center).97 Render(fmt.Sprintf("%d", hint))98 colHints = append(colHints, hintCell)99 }100 renderedCol := lipgloss.JoinVertical(lipgloss.Left, colHints...)101 renderedCols = append(renderedCols, renderedCol)102103 if needsSpacer(i, n) {104 sepStyle := lipgloss.NewStyle().Foreground(theme.Current().Border)105 var lines []string106 for range height - 1 {107 lines = append(lines, " ")108 }109 lines = append(lines, sepStyle.Render("\u2502"))110 renderedCols = append(renderedCols, lipgloss.JoinVertical(lipgloss.Left, lines...))111 }112 }113 return lipgloss.JoinHorizontal(lipgloss.Top, renderedCols...)114}115116func rowHintView(r TomographyDefinition, width int, current ...TomographyDefinition) string {117 var hasCurrent bool118 var curr TomographyDefinition119 if len(current) > 0 {120 hasCurrent = true121 curr = current[0]122 }123124 n := len(r)125 var renderedRows []string126 for i, hints := range r {127 satisfied := hasCurrent && i < len(curr) && intSliceEqual(hints, curr[i])128129 style := hintStyle()130 if satisfied {131 style = hintSatisfiedStyle()132 }133134 var rowHints []string135 for _, hint := range hints {136 hintCell := fmt.Sprintf("%2d", hint)137 rowHints = append(rowHints, hintCell)138 }139 renderedRow := style.Width(width).140 Align(lipgloss.Right).141 Render(strings.Join(rowHints, " "))142 renderedRows = append(renderedRows, renderedRow)143144 if needsSpacer(i, n) {145 sep := lipgloss.NewStyle().Foreground(theme.Current().Border).146 Width(width).147 Align(lipgloss.Right).148 Render("\u2500")149 renderedRows = append(renderedRows, sep)150 }151 }152 s := lipgloss.JoinVertical(lipgloss.Left, renderedRows...)153 return s154}155156type boardBlockLayout struct {157 Block string158 Grid string159 HintWidth int160 HintHeight int161 Metrics game.DynamicGridMetrics162}163164func nonogramMetrics(width, height int) game.DynamicGridMetrics {165 return game.DynamicGridMetrics{166 Width: width,167 Height: height,168 CellWidth: cellWidth,169 VerticalBridgeWidth: func(x int) int {170 return nonogramVerticalBridgeWidth(width, x)171 },172 HorizontalBridgeHeight: func(y int) int {173 return nonogramHorizontalBridgeHeight(height, y)174 },175 }176}177178func nonogramVerticalBridgeWidth(width, x int) int {179 switch {180 case x <= 0, x >= width:181 return 1182 case needsSpacer(x-1, width):183 return 1184 default:185 return 0186 }187}188189func nonogramHorizontalBridgeHeight(height, y int) int {190 switch {191 case y <= 0, y >= height:192 return 1193 case needsSpacer(y-1, height):194 return 1195 default:196 return 0197 }198}199200func buildBoardBlock(m Model) boardBlockLayout {201 maxWidth := m.rowHints.RequiredLen() * cellWidth202 maxHeight := m.colHints.RequiredLen()203 metrics := nonogramMetrics(m.width, m.height)204205 grid := gridView(m.grid, m.cursor, m.solved)206 rowHints := rowHintView(m.rowHints, maxWidth, m.currentHints.rows)207 colHints := colHintView(m.colHints, maxHeight, m.currentHints.cols)208 spacer := lipgloss.NewStyle().Width(maxWidth).Height(maxHeight).Render("")209 topBand := lipgloss.JoinHorizontal(lipgloss.Bottom, spacer, colHints)210 block := lipgloss.JoinVertical(211 lipgloss.Center,212 topBand,213 lipgloss.JoinHorizontal(lipgloss.Center, rowHints, grid),214 )215216 return boardBlockLayout{217 Block: block,218 Grid: grid,219 HintWidth: lipgloss.Width(rowHints),220 HintHeight: lipgloss.Height(topBand),221 Metrics: metrics,222 }223}224225func gridView(g grid, c game.Cursor, solved bool) string {226 height := len(g)227 width := 0228 if height > 0 {229 width = len(g[0])230 }231 metrics := nonogramMetrics(width, height)232233 return game.RenderDynamicGrid(game.DynamicGridSpec{234 Width: width,235 Height: height,236 CellWidth: cellWidth,237 Solved: solved,238 VerticalBridgeWidth: metrics.VerticalBridgeWidth,239 HorizontalBridgeHeight: metrics.HorizontalBridgeHeight,240 Cell: func(x, y int) string {241 return tileView(g[y][x], x == c.X && y == c.Y, y == c.Y, x == c.X, solved)242 },243 ZoneAt: func(_, _ int) int {244 return 0245 },246 HasVerticalEdge: func(x, _ int) bool {247 return x <= 0 || x >= width || needsSpacer(x-1, width)248 },249 HasHorizontalEdge: func(_, y int) bool {250 return y <= 0 || y >= height || needsSpacer(y-1, height)251 },252 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {253 return separatorBridgeFill(c, solved, width, height, bridge)254 },255 })256}257258func separatorBridgeFill(cursor game.Cursor, solved bool, width, height int, bridge game.DynamicGridBridge) color.Color {259 if solved {260 return nil261 }262263 switch bridge.Kind {264 case game.DynamicGridBridgeVertical:265 if bridge.X <= 0 || bridge.X >= width || !needsSpacer(bridge.X-1, width) {266 return nil267 }268 case game.DynamicGridBridgeHorizontal:269 if bridge.Y <= 0 || bridge.Y >= height || !needsSpacer(bridge.Y-1, height) {270 return nil271 }272 default:273 return nil274 }275276 if game.DynamicGridBridgeOnCrosshairAxis(cursor, bridge) {277 return theme.Current().Surface278 }279 return nil280}281282func tileView(val rune, isCursor, inCursorRow, inCursorCol, solved bool) string {283 p := theme.Current()284 styles := renderStyleMap()285 s, ok := styles[val]286 if !ok {287 s = styles[emptyTile]288 }289290 r, ok := renderRuneMap[val]291 if !ok {292 r = renderRuneMap[emptyTile]293 }294295 if isCursor && solved {296 s = game.CursorSolvedStyle()297 r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight298 } else if isCursor {299 s = game.CursorStyle()300 r = game.CursorLeft + string([]rune(r)[1]) + game.CursorRight301 } else if solved {302 s = s.Foreground(p.SolvedFG).Background(p.SuccessBG)303 } else if inCursorRow || inCursorCol {304 s = s.Background(p.Surface)305 }306307 return s.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(r)308}309310func statusBarView(showFullHelp bool) string {311 if showFullHelp {312 return game.StatusBarStyle().Render("arrows/wasd: move z: fill (hold+move) x: mark (hold+move) bkspc: clear LMB: fill RMB: mark esc: menu ctrl+r: reset ctrl+h: help")313 }314 return game.StatusBarStyle().Render("z: fill x: mark mouse: drag bkspc clear")315}316317func intSliceEqual(a, b []int) bool {318 if len(a) != len(b) {319 return false320 }321 for i, v := range a {322 if v != b[i] {323 return false324 }325 }326 return true327}