clone

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

feat/elo-difficulty
puzzletea / game / style.go
style.go 6.7K
1package game23import (4 "fmt"5 "image/color"6 "strings"78 "charm.land/lipgloss/v2"9 "github.com/FelineStateMachine/puzzletea/theme"10)1112// Shared cursor / conflict color accessors. Puzzle packages that compose13// styles on top of the cursor colors read these at render time.1415func CursorFG() color.Color { return theme.Current().AccentText }16func CursorBG() color.Color { return theme.Current().AccentBG }17func ConflictFG() color.Color { return theme.Current().Error }18func ConflictBG() color.Color { return theme.Current().ErrorBG }19func SolvedFG() color.Color { return theme.Current().SolvedFG }2021// SameColor reports whether two colors resolve to identical RGBA values.22func SameColor(left, right color.Color) bool {23 if left == nil || right == nil {24 return left == nil && right == nil25 }2627 lr, lg, lb, la := left.RGBA()28 rr, rg, rb, ra := right.RGBA()29 return lr == rr && lg == rg && lb == rb && la == ra30}3132// CursorStyle highlights the cursor position with an accent background.33func CursorStyle() lipgloss.Style {34 return lipgloss.NewStyle().35 Bold(true).36 Foreground(CursorFG()).37 Background(CursorBG())38}3940// CursorSolvedStyle highlights the cursor position on a solved grid.41func CursorSolvedStyle() lipgloss.Style {42 return lipgloss.NewStyle().43 Bold(true).44 Foreground(CursorFG()).45 Background(theme.Current().SuccessBG)46}4748// Bracket markers for cursor identification. These are prepended/appended49// to cell content so the cursor is identifiable independent of BG contrast.50const (51 CursorLeft = "\u25b8" // ▸52 CursorRight = "\u25c2" // ◂53)5455// StatusBarStyle returns the shared style for the status/help bar below each puzzle grid.56func StatusBarStyle() lipgloss.Style {57 return lipgloss.NewStyle().58 Foreground(theme.Current().TextDim).59 MarginTop(1)60}6162func titleStyle() lipgloss.Style {63 return lipgloss.NewStyle().64 Bold(true).65 Foreground(theme.Current().AccentText).66 Background(CursorBG()).67 Padding(0, 1)68}6970func solvedBadgeStyle() lipgloss.Style {71 return lipgloss.NewStyle().72 Bold(true).73 Foreground(theme.Current().SuccessBorder)74}7576// TitleBarView renders a title bar with the game name, mode name, and optional solved badge.77func TitleBarView(gameName, modeName string, solved bool) string {78 title := titleStyle().Render(gameName + " - " + modeName)79 if solved {80 badge := solvedBadgeStyle().Render(" SOLVED")81 return title + badge + "\nesc to return to menu"82 }83 return lipgloss.NewStyle().PaddingBottom(1).Render(title)84}8586// ComposeGameView joins title + primary content + optional secondary rows.87// Secondary rows are width-anchored to primary so long help/info text wraps88// instead of widening the full layout and shifting centered views.89func ComposeGameView(title, primary string, secondary ...string) string {90 rows := make([]SecondaryRow, 0, len(secondary))91 for _, row := range secondary {92 rows = append(rows, StaticRow(row))93 }94 return ComposeGameViewRows(title, primary, rows...)95}9697// SecondaryRow defines one auxiliary row below the main game grid.98// Variants can be provided to reserve the tallest wrapped height99// up-front and avoid layout shift when row content changes.100type SecondaryRow struct {101 Current string102 Variants []string103}104105func StaticRow(row string) SecondaryRow {106 return SecondaryRow{Current: row}107}108109func StableRow(current string, variants ...string) SecondaryRow {110 return SecondaryRow{Current: current, Variants: variants}111}112113// ComposeGameViewRows joins title + primary content + optional secondary rows.114// Secondary rows are rendered at 150% of primary width to reduce wrapping.115// If variants are provided for a row, the row reserves the max wrapped height116// across the variants and current value to avoid height jitter.117func ComposeGameViewRows(title, primary string, rows ...SecondaryRow) string {118 sections := []string{title, primary}119 primaryWidth := lipgloss.Width(primary)120 if primaryWidth <= 0 {121 for _, row := range rows {122 sections = append(sections, row.Current)123 }124 return lipgloss.JoinVertical(lipgloss.Center, sections...)125 }126127 secondaryWidth := primaryWidth + (primaryWidth / 2)128 rowStyle := lipgloss.NewStyle().Width(secondaryWidth).AlignHorizontal(lipgloss.Center)129 for _, row := range rows {130 rendered := wrapAndPadSecondary(row, secondaryWidth)131 sections = append(sections, rowStyle.Render(rendered))132 }133134 return lipgloss.JoinVertical(lipgloss.Center, sections...)135}136137func wrapAndPadSecondary(row SecondaryRow, width int) string {138 current := wrapRowOnDoubleSpace(row.Current, width)139 target := lipgloss.Height(current)140 for _, variant := range row.Variants {141 h := lipgloss.Height(wrapRowOnDoubleSpace(variant, width))142 if h > target {143 target = h144 }145 }146 if h := lipgloss.Height(current); target > h {147 current += strings.Repeat("\n", target-h)148 }149 return current150}151152func wrapRowOnDoubleSpace(row string, width int) string {153 if width <= 0 || !strings.Contains(row, " ") {154 return row155 }156157 lines := strings.Split(row, "\n")158 var out []string159 for _, line := range lines {160 out = append(out, wrapLineOnDoubleSpace(line, width)...)161 }162 return strings.Join(out, "\n")163}164165func wrapLineOnDoubleSpace(line string, width int) []string {166 if width <= 0 || lipgloss.Width(line) <= width || !strings.Contains(line, " ") {167 return []string{line}168 }169170 parts := strings.Split(line, " ")171 var (172 wrapped []string173 current string174 )175 for _, part := range parts {176 if part == "" {177 continue178 }179 if current == "" {180 current = part181 continue182 }183 candidate := current + " " + part184 if lipgloss.Width(candidate) <= width {185 current = candidate186 continue187 }188 wrapped = append(wrapped, current)189 current = part190 }191 if current != "" {192 wrapped = append(wrapped, current)193 }194 if len(wrapped) == 0 {195 return []string{line}196 }197 return wrapped198}199200// DebugHeader returns the markdown heading and property table header for debug info.201// rows is a list of [key, value] pairs for the "Game State" table.202func DebugHeader(title string, rows [][2]string) string {203 var sb strings.Builder204 fmt.Fprintf(&sb, "# %s\n\n## Game State\n\n", title)205 sb.WriteString("| Property | Value |\n| :--- | :--- |\n")206 for _, r := range rows {207 fmt.Fprintf(&sb, "| %s | %s |\n", r[0], r[1])208 }209 return sb.String()210}211212// DebugTable returns a markdown table section with a heading and arbitrary columns.213// headers is the column names, rows is a list of row values.214func DebugTable(heading string, headers []string, rows [][]string) string {215 var sb strings.Builder216 fmt.Fprintf(&sb, "\n## %s\n\n", heading)217218 sb.WriteString("|")219 for _, h := range headers {220 fmt.Fprintf(&sb, " %s |", h)221 }222 sb.WriteString("\n|")223 for range headers {224 sb.WriteString(" :--- |")225 }226 sb.WriteString("\n")227228 for _, row := range rows {229 sb.WriteString("|")230 for _, cell := range row {231 fmt.Fprintf(&sb, " %s |", cell)232 }233 sb.WriteString("\n")234 }235 return sb.String()236}