clone

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

refactor/root-tui-model
puzzletea / game / dynamic_grid.go
dynamic_grid.go 13.4K
1package game23import (4 "image"5 "image/color"6 "strings"78 "charm.land/lipgloss/v2"9)1011const DynamicGridCellWidth = 31213type DynamicGridMetrics struct {14 Width int15 Height int16 CellWidth int17 VerticalBridgeWidth func(x int) int18 HorizontalBridgeHeight func(y int) int19}2021type DynamicGridBridgeKind int2223const (24 DynamicGridBridgeVertical DynamicGridBridgeKind = iota25 DynamicGridBridgeHorizontal26 DynamicGridBridgeJunction27)2829type DynamicGridBridge struct {30 Kind DynamicGridBridgeKind31 X int32 Y int33 Cells [4]image.Point34 Count int35 Zone int36 Uniform bool37}3839type DynamicGridSpec struct {40 Width int41 Height int42 CellWidth int43 Solved bool44 Colors *GridBorderColors45 Cell func(x, y int) string46 ZoneAt func(x, y int) int47 HasVerticalEdge func(x, y int) bool48 HasHorizontalEdge func(x, y int) bool49 VerticalBridgeWidth func(x int) int50 HorizontalBridgeHeight func(y int) int51 ZoneFill func(zone int) color.Color52 BridgeFill func(bridge DynamicGridBridge) color.Color53 BridgeForeground func(bridge DynamicGridBridge) color.Color54 BridgeBold func(bridge DynamicGridBridge) bool55 VerticalBridgeText func(x, y int) string56 HorizontalBridgeText func(x, y int) string57}5859func RenderDynamicGrid(spec DynamicGridSpec) string {60 rows := make([]string, 0, spec.Height*2+1)61 for y := 0; y <= spec.Height; y++ {62 rowHeight := dynamicGridHorizontalBridgeHeight(spec, y)63 if rowHeight > 0 {64 row := dynamicGridBoundaryRow(spec, y)65 for range rowHeight {66 rows = append(rows, row)67 }68 }69 if y == spec.Height {70 continue71 }72 rows = append(rows, dynamicGridContentRow(spec, y))73 }74 return lipgloss.JoinVertical(lipgloss.Left, rows...)75}7677func dynamicGridCellWidth(spec DynamicGridSpec) int {78 if spec.CellWidth > 0 {79 return spec.CellWidth80 }81 return DynamicGridCellWidth82}8384func DynamicGridOrigin(termW, termH int, fullView, title, grid string) (x, y int) {85 viewWidth := lipgloss.Width(fullView)86 viewHeight := lipgloss.Height(fullView)87 if viewWidth > termW {88 viewWidth = termW89 }90 if viewHeight > termH {91 viewHeight = termH92 }9394 centerX := max((termW-viewWidth)/2, 0)95 centerY := max((termH-viewHeight)/2, 0)9697 titleHeight := strings.Count(title, "\n") + 198 gridWidth := lipgloss.Width(grid)99 gridPadLeft := max((viewWidth-gridWidth)/2, 0)100101 return centerX + 1 + gridPadLeft, centerY + 1 + titleHeight102}103104func DynamicGridScreenToCell(105 metrics DynamicGridMetrics,106 originX, originY, screenX, screenY int,107 includeSeparators bool,108) (col, row int, ok bool) {109 if metrics.Width <= 0 || metrics.Height <= 0 {110 return 0, 0, false111 }112113 cellWidth := metrics.CellWidth114 if cellWidth <= 0 {115 cellWidth = DynamicGridCellWidth116 }117118 lx := screenX - originX119 ly := screenY - originY120 if lx < 0 || ly < 0 {121 return 0, 0, false122 }123124 col, ok = dynamicGridLocateAxis(125 lx,126 metrics.Width,127 cellWidth,128 includeSeparators,129 func(x int) int {130 return dynamicGridMetricVerticalBridgeWidth(metrics, x)131 },132 )133 if !ok {134 return 0, 0, false135 }136137 row, ok = dynamicGridLocateAxis(138 ly,139 metrics.Height,140 1,141 includeSeparators,142 func(y int) int {143 return dynamicGridMetricHorizontalBridgeHeight(metrics, y)144 },145 )146 if !ok {147 return 0, 0, false148 }149150 if col < 0 || col >= metrics.Width || row < 0 || row >= metrics.Height {151 return 0, 0, false152 }153 return col, row, true154}155156// DynamicGridBridgeOnCrosshairAxis reports whether a bridge should be tinted157// by a row/column crosshair for the given cursor position.158func DynamicGridBridgeOnCrosshairAxis(cursor Cursor, bridge DynamicGridBridge) bool {159 switch bridge.Kind {160 case DynamicGridBridgeVertical:161 if bridge.Count == 0 {162 return bridge.Y == cursor.Y163 }164 for i := 0; i < bridge.Count; i++ {165 if bridge.Cells[i].Y == cursor.Y {166 return true167 }168 }169 case DynamicGridBridgeHorizontal:170 if bridge.Count == 0 {171 return bridge.X == cursor.X172 }173 for i := 0; i < bridge.Count; i++ {174 if bridge.Cells[i].X == cursor.X {175 return true176 }177 }178 }179 return false180}181182func dynamicGridContentRow(spec DynamicGridSpec, y int) string {183 var b strings.Builder184 for x := range spec.Width {185 bridge := dynamicGridVerticalBridge(spec, x, y)186 b.WriteString(dynamicGridRenderVerticalBridge(spec, x, y, bridge))187 if spec.Cell != nil {188 b.WriteString(spec.Cell(x, y))189 }190 }191192 bridge := dynamicGridVerticalBridge(spec, spec.Width, y)193 b.WriteString(dynamicGridRenderVerticalBridge(spec, spec.Width, y, bridge))194 return b.String()195}196197func dynamicGridBoundaryRow(spec DynamicGridSpec, y int) string {198 var b strings.Builder199 for x := 0; x <= spec.Width; x++ {200 junctionWidth := dynamicGridVerticalBridgeWidth(spec, x)201 if junctionWidth > 0 {202 runeValue := dynamicGridJunctionRune(spec, x, y)203 b.WriteString(dynamicGridRenderJunction(spec, x, y, junctionWidth, runeValue))204 }205 if x == spec.Width {206 continue207 }208 bridge := dynamicGridHorizontalBridge(spec, x, y)209 b.WriteString(dynamicGridRenderHorizontalBridge(spec, x, y, bridge))210 }211 return b.String()212}213214func dynamicGridColors(spec DynamicGridSpec) GridBorderColors {215 if spec.Colors != nil {216 return *spec.Colors217 }218 return DefaultBorderColors()219}220221func dynamicGridRenderBorderChar(ch rune, colors GridBorderColors, solved bool, bg color.Color) string {222 fg, bg := borderColors(colors, solved, bg)223 return lipgloss.NewStyle().224 Foreground(fg).225 Background(bg).226 Render(string(ch))227}228229func dynamicGridRenderVerticalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {230 bridgeWidth := dynamicGridVerticalBridgeWidth(spec, x)231 if bridgeWidth <= 0 {232 return ""233 }234235 if dynamicGridHasVerticalEdge(spec, x, y) {236 return strings.Repeat(237 dynamicGridRenderBorderChar(238 verticalEdgeRune(true),239 dynamicGridColors(spec),240 spec.Solved,241 dynamicGridBridgeBackground(spec, bridge),242 ),243 bridgeWidth,244 )245 }246247 if spec.VerticalBridgeText != nil {248 if text := strings.TrimSpace(spec.VerticalBridgeText(x, y)); text != "" {249 return dynamicGridRenderBridgeText(250 text,251 bridgeWidth,252 dynamicGridColors(spec),253 spec.Solved,254 dynamicGridBridgeBackground(spec, bridge),255 dynamicGridBridgeForeground(spec, bridge),256 dynamicGridBridgeBold(spec, bridge),257 )258 }259 }260261 return strings.Repeat(262 dynamicGridRenderBorderChar(263 verticalEdgeRune(false),264 dynamicGridColors(spec),265 spec.Solved,266 dynamicGridBridgeBackground(spec, bridge),267 ),268 bridgeWidth,269 )270}271272func dynamicGridRenderHorizontalBridge(spec DynamicGridSpec, x, y int, bridge DynamicGridBridge) string {273 cellWidth := dynamicGridCellWidth(spec)274 if dynamicGridHasHorizontalEdge(spec, x, y) {275 return strings.Repeat(276 dynamicGridRenderBorderChar(277 '─',278 dynamicGridColors(spec),279 spec.Solved,280 dynamicGridBridgeBackground(spec, bridge),281 ),282 cellWidth,283 )284 }285286 if spec.HorizontalBridgeText != nil {287 if text := strings.TrimSpace(spec.HorizontalBridgeText(x, y)); text != "" {288 return dynamicGridRenderBridgeText(289 text,290 cellWidth,291 dynamicGridColors(spec),292 spec.Solved,293 dynamicGridBridgeBackground(spec, bridge),294 dynamicGridBridgeForeground(spec, bridge),295 dynamicGridBridgeBold(spec, bridge),296 )297 }298 }299300 return strings.Repeat(301 dynamicGridRenderBorderChar(302 ' ',303 dynamicGridColors(spec),304 spec.Solved,305 dynamicGridBridgeBackground(spec, bridge),306 ),307 cellWidth,308 )309}310311func dynamicGridRenderBridgeText(312 text string,313 width int,314 colors GridBorderColors,315 solved bool,316 bg color.Color,317 fgOverride color.Color,318 bold bool,319) string {320 fg, bg := borderColors(colors, solved, bg)321 if fgOverride != nil {322 fg = fgOverride323 }324325 style := lipgloss.NewStyle().326 Width(width).327 AlignHorizontal(lipgloss.Center).328 Foreground(fg).329 Background(bg)330 if bold {331 style = style.Bold(true)332 }333 return style.Render(text)334}335336func dynamicGridHasHorizontalEdge(spec DynamicGridSpec, x, y int) bool {337 if spec.HasHorizontalEdge != nil {338 return spec.HasHorizontalEdge(x, y)339 }340341 switch {342 case y <= 0, y >= spec.Height:343 return true344 default:345 return dynamicGridZoneAt(spec, x, y-1) != dynamicGridZoneAt(spec, x, y)346 }347}348349func dynamicGridHasVerticalEdge(spec DynamicGridSpec, x, y int) bool {350 if spec.HasVerticalEdge != nil {351 return spec.HasVerticalEdge(x, y)352 }353354 switch {355 case x <= 0, x >= spec.Width:356 return true357 default:358 return dynamicGridZoneAt(spec, x-1, y) != dynamicGridZoneAt(spec, x, y)359 }360}361362func dynamicGridRenderJunction(spec DynamicGridSpec, x, y, width int, runeValue rune) string {363 if width <= 0 {364 return ""365 }366367 return dynamicGridRenderBridgeText(368 string(runeValue),369 width,370 dynamicGridColors(spec),371 spec.Solved,372 dynamicGridBridgeBackground(spec, dynamicGridJunctionBridge(spec, x, y, runeValue)),373 nil,374 false,375 )376}377378func dynamicGridJunctionRune(spec DynamicGridSpec, x, y int) rune {379 north := y > 0 && dynamicGridHasVerticalEdge(spec, x, y-1)380 south := y < spec.Height && dynamicGridHasVerticalEdge(spec, x, y)381 west := x > 0 && dynamicGridHasHorizontalEdge(spec, x-1, y)382 east := x < spec.Width && dynamicGridHasHorizontalEdge(spec, x, y)383384 switch {385 case north && south && west && east:386 return '┼'387 case north && south && west:388 return '┤'389 case north && south && east:390 return '├'391 case west && east && north:392 return '┴'393 case west && east && south:394 return '┬'395 case south && east:396 return '┌'397 case south && west:398 return '┐'399 case north && east:400 return '└'401 case north && west:402 return '┘'403 case north || south:404 return '│'405 case west || east:406 return '─'407 default:408 return ' '409 }410}411412func dynamicGridBridgeBackground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {413 if spec.BridgeFill != nil {414 if bg := spec.BridgeFill(bridge); bg != nil {415 return bg416 }417 }418 if bridge.Uniform && spec.ZoneFill != nil {419 return spec.ZoneFill(bridge.Zone)420 }421 return nil422}423424func dynamicGridBridgeForeground(spec DynamicGridSpec, bridge DynamicGridBridge) color.Color {425 if spec.BridgeForeground != nil {426 return spec.BridgeForeground(bridge)427 }428 return nil429}430431func dynamicGridBridgeBold(spec DynamicGridSpec, bridge DynamicGridBridge) bool {432 if spec.BridgeBold != nil {433 return spec.BridgeBold(bridge)434 }435 return false436}437438func dynamicGridLocateAxis(439 offset, count, cellSpan int,440 includeSeparators bool,441 bridgeSize func(index int) int,442) (index int, ok bool) {443 if count <= 0 || cellSpan <= 0 {444 return 0, false445 }446447 position := 0448 for i := range count {449 cellEnd := position + cellSpan450 if offset < cellEnd {451 return i, true452 }453454 position = cellEnd455 if i == count-1 {456 break457 }458459 size := max(bridgeSize(i+1), 0)460 if size <= 0 {461 continue462 }463464 bridgeEnd := position + size465 if offset < bridgeEnd {466 if !includeSeparators {467 return 0, false468 }469 return i + 1, true470 }471 position = bridgeEnd472 }473474 return 0, false475}476477func dynamicGridVerticalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {478 if dynamicGridHasVerticalEdge(spec, x, y) || x <= 0 || x >= spec.Width {479 return DynamicGridBridge{480 Kind: DynamicGridBridgeVertical,481 X: x,482 Y: y,483 }484 }485486 return dynamicGridBridge(487 spec,488 DynamicGridBridgeVertical,489 x,490 y,491 image.Pt(x-1, y),492 image.Pt(x, y),493 )494}495496func dynamicGridHorizontalBridge(spec DynamicGridSpec, x, y int) DynamicGridBridge {497 if dynamicGridHasHorizontalEdge(spec, x, y) || y <= 0 || y >= spec.Height {498 return DynamicGridBridge{499 Kind: DynamicGridBridgeHorizontal,500 X: x,501 Y: y,502 }503 }504505 return dynamicGridBridge(506 spec,507 DynamicGridBridgeHorizontal,508 x,509 y,510 image.Pt(x, y-1),511 image.Pt(x, y),512 )513}514515func dynamicGridJunctionBridge(spec DynamicGridSpec, x, y int, runeValue rune) DynamicGridBridge {516 if runeValue != ' ' {517 return DynamicGridBridge{518 Kind: DynamicGridBridgeJunction,519 X: x,520 Y: y,521 }522 }523 if x <= 0 || y <= 0 || x >= spec.Width || y >= spec.Height {524 return DynamicGridBridge{525 Kind: DynamicGridBridgeJunction,526 X: x,527 Y: y,528 }529 }530531 return dynamicGridBridge(532 spec,533 DynamicGridBridgeJunction,534 x,535 y,536 image.Pt(x-1, y-1),537 image.Pt(x, y-1),538 image.Pt(x-1, y),539 image.Pt(x, y),540 )541}542543func dynamicGridBridge(spec DynamicGridSpec, kind DynamicGridBridgeKind, x, y int, cells ...image.Point) DynamicGridBridge {544 bridge := DynamicGridBridge{545 Kind: kind,546 X: x,547 Y: y,548 Count: len(cells),549 Uniform: len(cells) > 0,550 }551552 for i := range cells {553 bridge.Cells[i] = cells[i]554 zone := dynamicGridZoneAt(spec, cells[i].X, cells[i].Y)555 if i == 0 {556 bridge.Zone = zone557 continue558 }559 if zone != bridge.Zone {560 bridge.Uniform = false561 }562 }563 return bridge564}565566func dynamicGridZoneAt(spec DynamicGridSpec, x, y int) int {567 if spec.ZoneAt == nil {568 return 0569 }570 return spec.ZoneAt(x, y)571}572573func dynamicGridVerticalBridgeWidth(spec DynamicGridSpec, x int) int {574 if spec.VerticalBridgeWidth == nil {575 return 1576 }577 return max(spec.VerticalBridgeWidth(x), 0)578}579580func dynamicGridHorizontalBridgeHeight(spec DynamicGridSpec, y int) int {581 if spec.HorizontalBridgeHeight == nil {582 return 1583 }584 return max(spec.HorizontalBridgeHeight(y), 0)585}586587func dynamicGridMetricVerticalBridgeWidth(metrics DynamicGridMetrics, x int) int {588 if metrics.VerticalBridgeWidth == nil {589 return 1590 }591 return max(metrics.VerticalBridgeWidth(x), 0)592}593594func dynamicGridMetricHorizontalBridgeHeight(metrics DynamicGridMetrics, y int) int {595 if metrics.HorizontalBridgeHeight == nil {596 return 1597 }598 return max(metrics.HorizontalBridgeHeight(y), 0)599}600601func verticalEdgeRune(hasEdge bool) rune {602 if hasEdge {603 return '│'604 }605 return ' '606}