(Feat): Initial Commit, Termdoku
This commit is contained in:
234
internal/ui/renderer.go
Normal file
234
internal/ui/renderer.go
Normal file
@@ -0,0 +1,234 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user