Files
termdoku/internal/ui/renderer.go

235 lines
5.6 KiB
Go

package ui
import (
"fmt"
"strings"
"time"
"termdoku/internal/game"
"github.com/charmbracelet/lipgloss"
)
func boardString(m Model) string {
var b strings.Builder
var dup [9][9]bool
if m.autoCheck {
dup = game.DuplicateMap(m.board.Values, m.cursorRow, m.cursorCol)
}
var conf [9][9]bool
if m.autoCheck {
conf = game.ConflictMap(m.board.Values, m.board.Given)
}
cellWidth := lipgloss.Width(m.styles.Cell.Render("0"))
buildLine := func(left, mid, right string) string {
seg := strings.Repeat("─", cellWidth)
var sb strings.Builder
sb.WriteString(left)
for c := range 9 {
sb.WriteString(seg)
switch c {
case 8:
sb.WriteString(right)
case 2, 5:
sb.WriteString(mid)
}
}
return sb.String()
}
topBorder := m.styles.RowSep.Render(buildLine("╭", "┬", "╮"))
midBorder := m.styles.RowSep.Render(buildLine("├", "┼", "┤"))
botBorder := m.styles.RowSep.Render(buildLine("╰", "┴", "╯"))
b.WriteString(topBorder)
b.WriteString("\n")
for r := range 9 {
b.WriteString(m.styles.ColSep.Render("│"))
for c := range 9 {
if c > 0 && c%3 == 0 {
b.WriteString(m.styles.ColSep.Render("│"))
}
cell := m.cellView(r, c, dup[r][c], conf[r][c])
b.WriteString(cell)
}
b.WriteString(m.styles.ColSep.Render("│"))
b.WriteString("\n")
if r == 2 || r == 5 {
b.WriteString(midBorder)
b.WriteString("\n")
}
}
b.WriteString(botBorder)
return b.String()
}
func Render(m Model) string {
if m.showHelp {
return renderHelpOverlay(m)
}
if m.paused {
return renderPauseScreen(m)
}
board := boardString(m)
status := lipgloss.PlaceHorizontal(46, lipgloss.Center, m.StatusLine())
// Win animation
if m.showWinAnim && time.Since(m.winAnimStart) < 2*time.Second {
winMsg := renderWinAnimation(m)
return board + "\n\n" + winMsg + "\n" + status
}
return board + "\n\n\n" + status
}
func (m Model) cellView(r, c int, isDup, isConf bool) string {
v := m.board.Values[r][c]
str := "·"
// Show notes if cell is empty and has notes
if v == 0 {
if notes, ok := m.notes[[2]int{r, c}]; ok && len(notes) > 0 {
// Show first note as indicator
str = string('₀' + rune(notes[0])) // subscript numbers
}
} else {
str = string('0' + v)
}
style := m.styles.Cell
if !m.showHelp && !m.paused && !m.completed {
inSameRow := r == m.cursorRow
inSameCol := c == m.cursorCol
inSameBox := (r/3 == m.cursorRow/3) && (c/3 == m.cursorCol/3)
if !inSameRow && !inSameCol && !inSameBox {
dimColor := "#555555"
if m.theme.Name == "light" {
dimColor = "#d1d5db"
}
style = style.Foreground(lipgloss.Color(dimColor))
} else if inSameRow || inSameCol || inSameBox {
highlightColor := "#6b7280"
if m.theme.Name == "light" {
highlightColor = "#9ca3af"
}
if r != m.cursorRow || c != m.cursorCol {
style = style.Foreground(lipgloss.Color(highlightColor))
}
}
}
if m.board.Given[r][c] {
style = m.styles.CellFixed
}
if isDup {
style = m.styles.CellDuplicate
}
if isConf {
style = m.styles.CellConflict
}
if r == m.cursorRow && c == m.cursorCol {
style = m.styles.CellSelected
}
if deadline, ok := m.flashes[[2]int{r, c}]; ok {
if time.Now().Before(deadline) {
style = style.Bold(true)
}
}
return style.Render(str)
}
func renderPauseScreen(m Model) string {
pauseMsg := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#fbbf24")).
Render("⏸ PAUSED")
hint := m.styles.Status.Render("Press 'p' to resume")
content := pauseMsg + "\n\n" + hint
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("#fbbf24")).
Padding(2, 4).
Render(content)
return lipgloss.Place(80, 24, lipgloss.Center, lipgloss.Center, box)
}
func renderHelpOverlay(m Model) string {
title := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(m.theme.Palette.Accent)).
Render("KEYBOARD SHORTCUTS")
helpItems := []struct {
keys string
desc string
}{
{"↑↓←→ / hjkl", "Move cursor"},
{"1-9", "Enter number"},
{"0 / Space", "Clear cell"},
{"n", "Toggle note mode"},
{"u / Ctrl+Z", "Undo"},
{"Ctrl+Y / Ctrl+R", "Redo"},
{"Ctrl+H", "Get hint"},
{"a", "Toggle auto-check"},
{"t", "Toggle timer"},
{"p", "Pause game"},
{"m", "Main menu"},
{"?", "Toggle this help"},
{"q / Esc", "Quit"},
}
var helpText strings.Builder
for _, item := range helpItems {
key := lipgloss.NewStyle().Foreground(lipgloss.Color("#60a5fa")).Bold(true).Render(item.keys)
desc := m.styles.Status.Render(item.desc)
helpText.WriteString(fmt.Sprintf(" %-25s %s\n", key, desc))
}
content := title + "\n\n" + helpText.String() + "\n" +
m.styles.Status.Render("Press '?' again to close")
box := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(m.theme.Palette.Accent)).
Padding(1, 2).
Render(content)
return lipgloss.Place(80, 30, lipgloss.Center, lipgloss.Center, box)
}
func renderWinAnimation(m Model) string {
elapsed := time.Since(m.winAnimStart).Milliseconds()
confetti := []string{"✨", "🎉", "🎊", "⭐", "💫", "🌟", "✦", "★"}
animPhase := int(elapsed / 150)
var confettiLine strings.Builder
for i := 0; i < 20; i++ {
if (i+animPhase)%3 == 0 {
symbol := confetti[(i+animPhase)%len(confetti)]
confettiLine.WriteString(symbol)
} else {
confettiLine.WriteString(" ")
}
}
colors := []string{"#10b981", "#3b82f6", "#8b5cf6", "#f59e0b", "#ef4444"}
colorIdx := (animPhase / 2) % len(colors)
mainMsg := "🏆 PUZZLE COMPLETE! 🏆"
styledMsg := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(colors[colorIdx])).
Render(mainMsg)
return confettiLine.String() + "\n" + styledMsg + "\n" + confettiLine.String()
}