Files
termdoku/internal/ui/menu.go

635 lines
17 KiB
Go

package ui
import (
_ "embed"
"fmt"
"strings"
"time"
"termdoku/internal/achievements"
"termdoku/internal/config"
"termdoku/internal/generator"
"termdoku/internal/stats"
"termdoku/internal/theme"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
//go:embed assets/banner.txt
var bannerArt string
type appState int
const (
stateMenu appState = iota
stateGame
stateStats
stateAchievements
stateLeaderboard
stateProfile
stateProfileSubmenu
stateDatabase
)
type App struct {
state appState
cfg config.Config
th theme.Theme
styles UIStyles
stats stats.Stats
achievements *achievements.Manager
menuItems []string
selectedIdx int
autoCheck bool
timerEnabled bool
width int
height int
currentDiff string
game Model
// Profile submenu
profileMenuItems []string
profileSelectedIdx int
}
func NewApp(cfg config.Config) App {
th := theme.GetTheme(cfg.Theme)
st, _ := stats.Load()
ach, _ := achievements.Load()
return App{
state: stateMenu,
cfg: cfg,
th: th,
styles: BuildStyles(th),
stats: st,
achievements: ach,
menuItems: []string{"Easy", "Normal", "Hard", "Expert", "Lunatic", "Daily", "Profile"},
selectedIdx: 1,
autoCheck: cfg.AutoCheck,
timerEnabled: cfg.TimerEnabled,
profileMenuItems: []string{"Achievements", "Leaderboard"},
profileSelectedIdx: 0,
}
}
func (a App) Init() tea.Cmd { return nil }
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch a.state {
case stateMenu:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "up", "k":
a.selectedIdx = clamp(a.selectedIdx-1, 0, len(a.menuItems)-1)
case "down", "j":
a.selectedIdx = clamp(a.selectedIdx+1, 0, len(a.menuItems)-1)
case "left", "h":
a.selectedIdx = clamp(a.selectedIdx-1, 0, len(a.menuItems)-1)
case "right", "l":
a.selectedIdx = clamp(a.selectedIdx+1, 0, len(a.menuItems)-1)
case "a":
a.autoCheck = !a.autoCheck
case "t":
a.timerEnabled = !a.timerEnabled
case "enter":
sel := a.menuItems[a.selectedIdx]
switch sel {
case "Achievements":
a.state = stateAchievements
return a, nil
case "Leaderboard":
a.state = stateLeaderboard
return a, nil
case "Profile":
a.state = stateProfile
return a, nil
default:
gm, cmd := a.startGame()
a.game = gm
a.state = stateGame
return a, cmd
}
case "q", "esc", "ctrl+c":
return a, tea.Quit
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateStats:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateGame:
// Check if game was just completed
wasCompleted := a.game.completed
// intercept main menu key
if kmsg, isKey := msg.(tea.KeyMsg); isKey {
if kmsg.String() == "m" {
// Record game if it was completed but not yet recorded
if a.game.completed && !wasCompleted {
a.recordGameCompletion()
}
a.state = stateMenu
return a, nil
}
}
gm, cmd := a.game.Update(msg)
if v, ok := gm.(Model); ok {
// Check if game just became completed
if v.completed && !wasCompleted {
a.game = v
a.recordGameCompletion()
return a, cmd
}
a.game = v
}
return a, cmd
case stateAchievements:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateLeaderboard:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateProfile:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
case "s":
a.state = stateProfileSubmenu
return a, nil
case "d":
a.state = stateDatabase
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateProfileSubmenu:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc":
a.state = stateProfile
return a, nil
case "up", "k":
a.profileSelectedIdx = clamp(a.profileSelectedIdx-1, 0, len(a.profileMenuItems)-1)
case "down", "j":
a.profileSelectedIdx = clamp(a.profileSelectedIdx+1, 0, len(a.profileMenuItems)-1)
case "enter":
sel := a.profileMenuItems[a.profileSelectedIdx]
switch sel {
case "Stats":
a.state = stateStats
return a, nil
case "Achievements":
a.state = stateAchievements
return a, nil
case "Leaderboard":
a.state = stateLeaderboard
return a, nil
}
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
case stateDatabase:
switch m := msg.(type) {
case tea.KeyMsg:
s := m.String()
switch s {
case "m", "q", "esc", "enter":
a.state = stateMenu
return a, nil
case "p":
a.state = stateProfile
return a, nil
}
case tea.WindowSizeMsg:
a.width, a.height = m.Width, m.Height
}
return a, nil
}
return a, nil
}
func (a App) View() string {
switch a.state {
case stateMenu:
return a.viewMenu()
case stateGame:
return a.viewGame()
case stateAchievements:
return a.viewAchievements()
case stateProfile:
return a.viewProfile()
case stateProfileSubmenu:
return a.viewProfileSubmenu()
case stateDatabase:
return a.viewDatabaseInfo()
case stateLeaderboard:
return a.viewLeaderboard()
}
return ""
}
func (a App) recordGameCompletion() {
record := stats.GameRecord{
Difficulty: a.currentDiff,
Completed: a.game.completed,
Time: int(a.game.elapsed.Seconds()),
HintsUsed: a.game.hintsUsed,
Date: time.Now(),
IsDaily: a.currentDiff == "Daily",
}
if a.currentDiff == "Daily" {
record.DailySeed = time.Now().Format("2006-01-02")
}
a.stats.RecordGame(record)
_ = stats.Save(a.stats)
if a.game.completed {
a.achievements.CheckAndUnlock("first_win", a.stats.CompletedGames)
if a.game.hintsUsed == 0 {
a.achievements.CheckAndUnlock("perfectionist", 1)
}
if a.currentDiff == "Easy" && int(a.game.elapsed.Seconds()) < 180 {
a.achievements.CheckAndUnlock("speed_demon", 1)
}
if a.currentDiff == "Hard" && !a.autoCheck {
a.achievements.CheckAndUnlock("no_mistakes", 1)
}
a.achievements.CheckAndUnlock("streak_master", a.stats.CurrentStreak)
a.achievements.CheckAndUnlock("century", a.stats.CompletedGames)
if a.currentDiff == "Lunatic" {
lunaticCount := a.stats.CompletionCounts["Lunatic"]
a.achievements.CheckAndUnlock("lunatic_legend", lunaticCount)
}
if a.currentDiff == "Daily" {
dailyCount := len(a.stats.DailyHistory)
a.achievements.CheckAndUnlock("daily_devotee", dailyCount)
}
_ = achievements.Save(a.achievements)
}
}
func (a *App) startGame() (Model, tea.Cmd) {
var g generator.Grid
var err error
sel := a.menuItems[a.selectedIdx]
switch sel {
case "Daily":
g, err = generator.GenerateDaily(time.Now())
case "Easy":
g, err = generator.Generate(generator.Easy, "")
case "Normal":
g, err = generator.Generate(generator.Normal, "")
case "Hard":
g, err = generator.Generate(generator.Hard, "")
case "Expert":
g, err = generator.Generate(generator.Expert, "")
case "Lunatic":
g, err = generator.Generate(generator.Lunatic, "")
}
if err != nil {
return a.game, nil
}
cfg := a.cfg
cfg.AutoCheck = a.autoCheck
cfg.TimerEnabled = a.timerEnabled
a.currentDiff = sel
m := New(g, a.th, cfg)
m.difficulty = sel
adaptiveColors := theme.NewAdaptiveColors(a.th)
diffColors := adaptiveColors.GetDifficultyColors()
hex := diffColors[sel]
if hex == "" {
hex = a.th.Palette.Accent
}
style := lipgloss.NewStyle().Foreground(lipgloss.Color(hex))
m.styles.RowSep = style
m.styles.ColSep = style
m.styles.CellFixed = m.styles.CellFixed.Foreground(lipgloss.Color(hex))
return m, m.Init()
}
func (a App) viewMenu() string {
banner := bannerArt
// Options
optAC := fmt.Sprintf("Auto-Check (a): %s", boolText(a.styles, a.autoCheck))
optTM := fmt.Sprintf("Timer (t): %s", boolText(a.styles, a.timerEnabled))
// Adaptive colors
adaptiveColors := theme.NewAdaptiveColors(a.th)
accentColors := adaptiveColors.GetAccentColors()
// Display all menu items
var items []string
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color(accentColors["selected"])).Bold(true)
for i, name := range a.menuItems {
prefix := " "
if i == a.selectedIdx {
prefix := "✭ "
label := prefix + name
items = append(items, selectedStyle.Render(label))
} else {
label := prefix + name
items = append(items, a.styles.MenuItem.Render(label))
}
}
gap := strings.Repeat(" ", 2)
diffRow := strings.Join(items, gap)
// Adaptive gradient colors
gradientColors := adaptiveColors.GetGradientColors()
bannerGrad := gradientColors["banner"]
leftHex := bannerGrad[0]
rightHex := bannerGrad[1]
title := gradientText("Select option", leftHex, rightHex)
box := renderGradientBox(diffRow, 2, leftHex, rightHex)
// Gradient banner (line by line)
var gb strings.Builder
for i, l := range strings.Split(strings.TrimRight(banner, "\n"), "\n") {
gb.WriteString(gradientText(l, leftHex, rightHex))
if i < len(strings.Split(strings.TrimRight(banner, "\n"), "\n"))-1 {
gb.WriteString("\n")
}
}
gradientBanner := gb.String()
// Compose content with explicit 2-line top/bottom padding
content := "\n\n" + gradientBanner + "\n\n\n" + optAC + "\n" + optTM + "\n\n\n" + title + "\n" + box + "\n\n"
panel := a.styles.Panel.Render(content)
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
func boolText(s UIStyles, v bool) string {
if v {
return s.BoolTrue.Render("ON")
}
return s.BoolFalse.Render("OFF")
}
func (a App) viewGame() string {
innerWidth := 58
boardAndStatus := Render(a.game)
label := a.currentDiff
if a.currentDiff == "Daily" {
label = "Daily Seed"
}
headerText := label + " Mode"
// Adaptive colors for headers
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
var header string
switch a.currentDiff {
case "Easy":
easyGrad := gradientColors["easy"]
header = gradientText(headerText, easyGrad[0], easyGrad[1])
case "Normal":
normalGrad := gradientColors["normal"]
header = gradientText(headerText, normalGrad[0], normalGrad[1])
case "Hard":
hardGrad := gradientColors["hard"]
header = gradientText(headerText, hardGrad[0], hardGrad[1])
case "Lunatic":
lunaticGrad := gradientColors["lunatic"]
header = gradientText(headerText, lunaticGrad[0], lunaticGrad[1])
case "Daily":
dailyGrad := gradientColors["daily"]
header = gradientText(headerText, dailyGrad[0], dailyGrad[1])
default:
header = lipgloss.NewStyle().Foreground(lipgloss.Color(a.th.Palette.Accent)).Bold(true).Render(headerText)
}
headerCentered := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, header)
centered := lipgloss.PlaceHorizontal(innerWidth, lipgloss.Center, boardAndStatus)
body := "\n" + headerCentered + "\n\n" + centered + "\n"
panel := a.styles.Panel.Render(body)
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
// Helpers: gradient text and gradient bordered box
func renderGradientBox(content string, padX int, leftHex, rightHex string) string {
w := lipgloss.Width(content) + padX*2
top := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("╭") + gradientLine("─", w, leftHex, rightHex) + lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("╮")
bottom := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("╰") + gradientLine("─", w, leftHex, rightHex) + lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("╯")
left := lipgloss.NewStyle().Foreground(lipgloss.Color(leftHex)).Render("│")
right := lipgloss.NewStyle().Foreground(lipgloss.Color(rightHex)).Render("│")
middle := left + strings.Repeat(" ", padX) + content + strings.Repeat(" ", padX) + right
return strings.Join([]string{top, middle, bottom}, "\n")
}
func gradientLine(ch string, width int, fromHex, toHex string) string {
colors := gradientColors(fromHex, toHex, width)
var b strings.Builder
for i := 0; i < width; i++ {
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colors[i])).Render(ch))
}
return b.String()
}
func gradientText(text, leftHex, rightHex string) string {
colors := gradientColors(leftHex, rightHex, len(text))
var b strings.Builder
idx := 0
for _, ch := range text { // rune-safe
b.WriteString(lipgloss.NewStyle().Foreground(lipgloss.Color(colors[idx])).Bold(true).Render(string(ch)))
idx++
}
return b.String()
}
func gradientColors(fromHex, toHex string, steps int) []string {
r1, g1, b1 := hexToRGB(fromHex)
r2, g2, b2 := hexToRGB(toHex)
out := make([]string, steps)
for i := 0; i < steps; i++ {
if steps == 1 {
out[i] = fromHex
continue
}
t := float64(i) / float64(steps-1)
r := int(float64(r1) + (float64(r2)-float64(r1))*t)
g := int(float64(g1) + (float64(g2)-float64(g1))*t)
b := int(float64(b1) + (float64(b2)-float64(b1))*t)
out[i] = fmt.Sprintf("#%02x%02x%02x", r, g, b)
}
return out
}
func hexToRGB(hex string) (int, int, int) {
h := strings.TrimPrefix(hex, "#")
if len(h) != 6 {
return 255, 255, 255
}
var r, g, b int
fmt.Sscanf(h, "%02x%02x%02x", &r, &g, &b)
return r, g, b
}
func (a App) viewAchievements() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
achGrad := gradientColors["banner"]
title := gradientText("Achievements", achGrad[0], achGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
unlockedCount := a.achievements.GetUnlockedCount()
totalCount := a.achievements.GetTotalCount()
progressText := fmt.Sprintf("Unlocked: %d/%d\n\n", unlockedCount, totalCount)
content.WriteString(a.styles.Status.Render(progressText))
achievementOrder := []string{
"first_win", "perfectionist", "speed_demon", "no_mistakes",
"streak_master", "lunatic_legend", "daily_devotee", "century",
}
for _, id := range achievementOrder {
if ach, ok := a.achievements.Achievements[id]; ok {
var line string
if ach.Unlocked {
line = fmt.Sprintf("%s %s - %s",
ach.Icon,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#10b981")).Render(ach.Name),
ach.Description)
} else {
progressBar := ""
if ach.Target > 1 {
progressBar = fmt.Sprintf(" [%d/%d]", ach.Progress, ach.Target)
}
line = fmt.Sprintf("%s %s - %s%s",
lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render("🔒"),
lipgloss.NewStyle().Foreground(lipgloss.Color("#9ca3af")).Render(ach.Name),
lipgloss.NewStyle().Foreground(lipgloss.Color("#6b7280")).Render(ach.Description),
progressBar)
}
content.WriteString(line + "\n")
}
}
content.WriteString("\n" + a.styles.Status.Render("Press 'm' or Enter to return to menu"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}
func (a App) viewLeaderboard() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
leaderGrad := gradientColors["banner"]
title := gradientText("Leaderboard", leaderGrad[0], leaderGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
difficulties := []string{"Easy", "Normal", "Hard", "Lunatic"}
for _, diff := range difficulties {
diffColors := adaptiveColors.GetDifficultyColors()
diffColor := diffColors[diff]
diffHeader := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(diffColor)).Render(diff)
content.WriteString(diffHeader + ":\n")
leaderboard := a.stats.GetLeaderboard(diff, 5)
if len(leaderboard) == 0 {
content.WriteString(a.styles.Status.Render(" No records yet\n\n"))
} else {
for i, record := range leaderboard {
medal := " "
switch i {
case 0:
medal = "🥇"
case 1:
medal = "🥈"
case 2:
medal = "🥉"
}
timeStr := stats.FormatTime(record.Time)
line := fmt.Sprintf("%s %d. %s", medal, i+1, timeStr)
if record.HintsUsed > 0 {
line += fmt.Sprintf(" (%d hints)", record.HintsUsed)
}
content.WriteString(a.styles.Status.Render(line + "\n"))
}
content.WriteString("\n")
}
}
content.WriteString(a.styles.Status.Render("Press 'm' or Enter to return to menu"))
panel := a.styles.Panel.Render(content.String())
if a.width > 0 && a.height > 0 {
return a.styles.App.Render(lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, panel))
}
return a.styles.App.Render(panel)
}