clone

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

chore/regenerate-vhs-gifs
puzzletea / takuzuplus / style.go
style.go 10.2K
1package takuzuplus23import (4 "image/color"5 "strconv"6 "strings"78 "charm.land/lipgloss/v2"9 "github.com/FelineStateMachine/puzzletea/game"10 "github.com/FelineStateMachine/puzzletea/theme"11)1213const cellWidth = game.DynamicGridCellWidth1415var renderRuneMap = map[rune]string{16 zeroCell: " ● ",17 oneCell: " ○ ",18 emptyCell: " · ",19}2021func zeroStyle() lipgloss.Style {22 p := theme.Current()23 return lipgloss.NewStyle().Foreground(p.Accent).Background(p.BG)24}2526func oneStyle() lipgloss.Style {27 p := theme.Current()28 return lipgloss.NewStyle().Foreground(p.Secondary).Background(p.BG)29}3031func emptyStyle() lipgloss.Style {32 p := theme.Current()33 return lipgloss.NewStyle().Foreground(p.TextDim).Background(p.BG)34}3536func renderStyleMap() map[rune]lipgloss.Style {37 return map[rune]lipgloss.Style{38 zeroCell: zeroStyle(),39 oneCell: oneStyle(),40 emptyCell: emptyStyle(),41 }42}4344type countPair struct {45 zeros int46 ones int47}4849type countContext struct {50 row countPair51 col countPair52 target int53}5455func cellView(val rune, isProvided, isCursor, inCursorRow, inCursorCol, solved, inDuplicateRow, inDuplicateCol bool) string {56 p := theme.Current()57 styles := renderStyleMap()58 style, ok := styles[val]59 if !ok {60 style = emptyStyle()61 }6263 text, ok := renderRuneMap[val]64 if !ok {65 text = renderRuneMap[emptyCell]66 }6768 switch {69 case solved:70 style = style.Foreground(p.SolvedFG).Background(p.SuccessBG)71 }7273 if isProvided && val != emptyCell && !solved {74 style = style.Bold(true).Background(theme.GivenTint(p.BG))75 }76 if (inDuplicateRow || inDuplicateCol) && !solved {77 style = style.Foreground(game.ConflictFG()).Background(game.ConflictBG())78 text = conflictText(text)79 }80 if isCursor {81 text = cursorText(text)82 }8384 return style.Width(cellWidth).AlignHorizontal(lipgloss.Center).Render(text)85}8687func cursorText(text string) string {88 runes := []rune(text)89 if len(runes) != cellWidth {90 return text91 }9293 switch {94 case runes[0] == ' ' && runes[cellWidth-1] == ' ':95 return game.CursorLeft + string(runes[1]) + game.CursorRight96 case runes[cellWidth-1] == ' ':97 return game.CursorLeft + string(runes[:cellWidth-1])98 case runes[0] == ' ':99 return string(runes[1:]) + game.CursorRight100 default:101 return text102 }103}104105func conflictText(text string) string {106 runes := []rune(text)107 if len(runes) != cellWidth {108 return text109 }110 return "!" + string(runes[1]) + "!"111}112113func rowComplete(row []rune) bool {114 for _, r := range row {115 if r == emptyCell {116 return false117 }118 }119 return true120}121122func colComplete(g grid, size, col int) bool {123 for y := range size {124 if col >= len(g[y]) || g[y][col] == emptyCell {125 return false126 }127 }128 return true129}130131func rowsEqual(a, b []rune) bool {132 if len(a) != len(b) {133 return false134 }135 for i := range a {136 if a[i] != b[i] {137 return false138 }139 }140 return true141}142143func colsEqual(g grid, size, c1, c2 int) bool {144 for r := range size {145 if g[r][c1] != g[r][c2] {146 return false147 }148 }149 return true150}151152func duplicateRowSet(g grid, size int) map[int]bool {153 dup := map[int]bool{}154 for i := range size {155 if !rowComplete(g[i]) {156 continue157 }158 for j := i + 1; j < size; j++ {159 if rowComplete(g[j]) && rowsEqual(g[i], g[j]) {160 dup[i] = true161 dup[j] = true162 }163 }164 }165 return dup166}167168func duplicateColSet(g grid, size int) map[int]bool {169 dup := map[int]bool{}170 for i := range size {171 if !colComplete(g, size, i) {172 continue173 }174 for j := i + 1; j < size; j++ {175 if colComplete(g, size, j) && colsEqual(g, size, i, j) {176 dup[i] = true177 dup[j] = true178 }179 }180 }181 return dup182}183184func gridView(m Model) string {185 dupRows := duplicateRowSet(m.grid, m.size)186 dupCols := duplicateColSet(m.grid, m.size)187 return game.RenderDynamicGrid(game.DynamicGridSpec{188 Width: m.size,189 Height: m.size,190 Solved: m.solved,191 Cell: func(x, y int) string {192 return cellView(193 m.grid[y][x],194 m.provided[y][x],195 x == m.cursor.X && y == m.cursor.Y,196 y == m.cursor.Y,197 x == m.cursor.X,198 m.solved,199 dupRows[y],200 dupCols[x],201 )202 },203 ZoneAt: func(_, _ int) int {204 return 0205 },206 BridgeFill: func(bridge game.DynamicGridBridge) color.Color {207 return bridgeFill(m, bridge)208 },209 BridgeBold: func(bridge game.DynamicGridBridge) bool {210 return relationBridgeState(m, bridge) != 0211 },212 VerticalBridgeText: func(x, y int) string {213 if x <= 0 || x >= m.size {214 return ""215 }216 rel := m.relations.horizontal[y][x-1]217 if rel == relationNone {218 return ""219 }220 return relationBridgeText(rel, m.grid[y][x-1], m.grid[y][x])221 },222 HorizontalBridgeText: func(x, y int) string {223 if y <= 0 || y >= m.size {224 return ""225 }226 rel := m.relations.vertical[y-1][x]227 if rel == relationNone {228 return ""229 }230 return relationBridgeText(rel, m.grid[y-1][x], m.grid[y][x])231 },232 })233}234235func bridgeFill(m Model, bridge game.DynamicGridBridge) color.Color {236 if bg := relationBridgeBackground(m, bridge); bg != nil {237 return bg238 }239 return nil240}241242func relationBridgeBackground(m Model, bridge game.DynamicGridBridge) color.Color {243 switch bridge.Kind {244 case game.DynamicGridBridgeVertical:245 if bridge.X <= 0 || bridge.X >= m.size || bridge.Y < 0 || bridge.Y >= m.size {246 return nil247 }248 return relationStateBackground(relationState(m.relations.horizontal[bridge.Y][bridge.X-1], m.grid[bridge.Y][bridge.X-1], m.grid[bridge.Y][bridge.X]))249 case game.DynamicGridBridgeHorizontal:250 if bridge.Y <= 0 || bridge.Y >= m.size || bridge.X < 0 || bridge.X >= m.size {251 return nil252 }253 return relationStateBackground(relationState(m.relations.vertical[bridge.Y-1][bridge.X], m.grid[bridge.Y-1][bridge.X], m.grid[bridge.Y][bridge.X]))254 default:255 return nil256 }257}258259func relationState(rel, left, right rune) int {260 if rel == relationNone || left == emptyCell || right == emptyCell {261 return 0262 }263 if relationSatisfied(rel, left, right) {264 return 1265 }266 return -1267}268269func relationStateBackground(state int) color.Color {270 switch state {271 case 1:272 return theme.Current().SuccessBG273 case -1:274 return game.ConflictBG()275 default:276 return nil277 }278}279280func relationBridgeState(m Model, bridge game.DynamicGridBridge) int {281 switch bridge.Kind {282 case game.DynamicGridBridgeVertical:283 if bridge.X <= 0 || bridge.X >= m.size || bridge.Y < 0 || bridge.Y >= m.size {284 return 0285 }286 return relationState(287 m.relations.horizontal[bridge.Y][bridge.X-1],288 m.grid[bridge.Y][bridge.X-1],289 m.grid[bridge.Y][bridge.X],290 )291 case game.DynamicGridBridgeHorizontal:292 if bridge.Y <= 0 || bridge.Y >= m.size || bridge.X < 0 || bridge.X >= m.size {293 return 0294 }295 return relationState(296 m.relations.vertical[bridge.Y-1][bridge.X],297 m.grid[bridge.Y-1][bridge.X],298 m.grid[bridge.Y][bridge.X],299 )300 default:301 return 0302 }303}304305func relationBridgeText(rel, left, right rune) string {306 _ = left307 _ = right308 return string(rel)309}310311func countContextView(m Model) string {312 ctx := buildCountContext(m.grid, m.cursor, m.size)313 width := countValueWidth(ctx.target)314 label := lipgloss.NewStyle().Foreground(theme.Current().Info)315316 var b strings.Builder317 b.WriteString(label.Render("row "))318 b.WriteString(countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":"))319 b.WriteString(renderCountValue(ctx.row.zeros, ctx.target, zeroCell, width))320 b.WriteString(label.Render(" "))321 b.WriteString(countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":"))322 b.WriteString(renderCountValue(ctx.row.ones, ctx.target, oneCell, width))323 b.WriteString(label.Render(" col "))324 b.WriteString(countLabelStyle(zeroCell).Render(string([]rune(renderRuneMap[zeroCell])[1]) + ":"))325 b.WriteString(renderCountValue(ctx.col.zeros, ctx.target, zeroCell, width))326 b.WriteString(label.Render(" "))327 b.WriteString(countLabelStyle(oneCell).Render(string([]rune(renderRuneMap[oneCell])[1]) + ":"))328 b.WriteString(renderCountValue(ctx.col.ones, ctx.target, oneCell, width))329 return b.String()330}331332func buildCountContext(g grid, cursor game.Cursor, size int) countContext {333 if size <= 0 || len(g) == 0 {334 return countContext{}335 }336 row := 0337 if cursor.Y >= 0 && cursor.Y < len(g) {338 row = cursor.Y339 }340 col := 0341 if cursor.X >= 0 && len(g[row]) > 0 && cursor.X < len(g[row]) {342 col = cursor.X343 }344 return countContext{345 row: countLine(g[row]),346 col: countColumn(g, col, size),347 target: size / 2,348 }349}350351func countLine(row []rune) countPair {352 var counts countPair353 for _, value := range row {354 switch value {355 case zeroCell:356 counts.zeros++357 case oneCell:358 counts.ones++359 }360 }361 return counts362}363364func countColumn(g grid, col, size int) countPair {365 var counts countPair366 for y := 0; y < size && y < len(g); y++ {367 if col < 0 || col >= len(g[y]) {368 continue369 }370 switch g[y][col] {371 case zeroCell:372 counts.zeros++373 case oneCell:374 counts.ones++375 }376 }377 return counts378}379380func countPairString(counts countPair) string {381 return strconv.Itoa(counts.zeros) + "/" + strconv.Itoa(counts.ones)382}383384func renderCountValue(count, target int, value rune, width int) string {385 text := strconv.Itoa(count) + "/" + strconv.Itoa(target)386 style := countValueStyle(value)387 if count == target {388 style = metGoalCountStyle(value)389 }390 if count > target {391 style = overGoalCountStyle()392 }393 return style.Width(width).AlignHorizontal(lipgloss.Right).Render(text)394}395396func countValueWidth(target int) int {397 return len(strconv.Itoa(target))*2 + 1398}399400func countValueStyle(value rune) lipgloss.Style {401 return lipgloss.NewStyle().Foreground(binaryValueColor(value))402}403404func countLabelStyle(value rune) lipgloss.Style {405 return lipgloss.NewStyle().406 Bold(true).407 Foreground(binaryValueColor(value))408}409410func metGoalCountStyle(value rune) lipgloss.Style {411 bg := binaryValueColor(value)412 return lipgloss.NewStyle().413 Bold(true).414 Foreground(theme.TextOnBG(bg)).415 Background(bg)416}417418func overGoalCountStyle() lipgloss.Style {419 bg := game.ConflictBG()420 return lipgloss.NewStyle().421 Bold(true).422 Foreground(theme.TextOnBG(bg)).423 Background(bg)424}425426func binaryValueColor(value rune) color.Color {427 p := theme.Current()428 switch value {429 case zeroCell:430 return p.Accent431 case oneCell:432 return p.Secondary433 default:434 return p.TextDim435 }436}437438func statusBarView(showFullHelp bool) string {439 if showFullHelp {440 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")441 }442 return game.StatusBarStyle().Render("mouse: click/cycle z/0: ● x/1: ○ fixed clues: = and x")443}