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) }