(Feat): Initial Commit, Termdoku

This commit is contained in:
2025-11-25 21:09:27 +00:00
commit f6933958e2
40 changed files with 5755 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
,----,
,/ .`|
,` .' : ____ ,---, ,-.
; ; / ,' , `. .' .' `\ ,--/ /|
.'___,/ ,' __ ,-. ,-+-,.' _ |,---.' \ ,---. ,--. :/ | ,--,
| : | ,' ,'/ /| ,-+-. ; , ||| | .`\ | ' ,'\ : : ' / ,'_ /|
; |.'; ; ,---. ' | |' | ,--.'|' | ||: : | ' | / / || ' / .--. | | :
`----' | | / \ | | ,'| | ,', | |,| ' ' ; :. ; ,. :' | : ,'_ /| : . |
' : ; / / |' : / | | / | |--' ' | ; . |' | |: :| | \ | ' | | . .
| | '. ' / || | ' | : | | , | | : | '' | .; :' : |. \ | | ' | | |
' : |' ; /|; : | | : | |/ ' : | / ; | : || | ' \ \: | : ; ; |
; |.' ' | / || , ; | | |`-' | | '` ,/ \ \ / ' : |--' ' : `--' \
'---' | : | ---' | ;/ ; : .' `----' ; |,' : , .-./
\ \ / '---' | ,.' '--' `--`----'
`----' '---'

20
internal/ui/example.go Normal file
View File

@@ -0,0 +1,20 @@
package ui
import (
"strings"
"termdoku/internal/theme"
)
// ExampleRenderSample returns a sample rendering string for docs/tests.
func ExampleRenderSample() string {
_th := theme.Darcula()
styles := BuildStyles(_th)
var b strings.Builder
b.WriteString(styles.CellSelected.Render("5"))
b.WriteString(" ")
b.WriteString(styles.CellDuplicate.Render("5"))
b.WriteString(" ")
b.WriteString(styles.CellConflict.Render("3"))
b.WriteString("\n")
return styles.App.Render(b.String())
}

473
internal/ui/game.go Normal file
View File

@@ -0,0 +1,473 @@
package ui
import (
"fmt"
"strconv"
"strings"
"time"
"termdoku/internal/config"
"termdoku/internal/game"
"termdoku/internal/generator"
"termdoku/internal/savegame"
"termdoku/internal/solver"
"termdoku/internal/theme"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
)
type timerTickMsg struct{}
type flashDoneMsg struct{ Row, Col int }
type Model struct {
keymap KeyMap
styles UIStyles
theme theme.Theme
board game.Board
solution game.Grid
cursorRow int
cursorCol int
autoCheck bool
timerEnabled bool
startTime time.Time
elapsed time.Duration
completed bool
paused bool
difficulty string
undoStack []game.Move
redoStack []game.Move
flashes map[[2]int]time.Time
showHelp bool
hintsUsed int
noteMode bool
notes map[[2]int][]uint8 // cell -> candidate numbers
showWinAnim bool
winAnimStart time.Time
}
func New(p generator.Grid, th theme.Theme, cfg config.Config) Model {
b := game.NewBoardFromPuzzle(game.Grid(p))
// Solve once for auto-check
sg := b.Values
if s := solveCopy(b.Values); s != nil {
sg = *s
}
km := DefaultKeyMap()
km.ApplyBindings(cfg.Bindings)
m := Model{
keymap: km,
styles: BuildStyles(th),
theme: th,
board: b,
solution: sg,
cursorRow: 0,
cursorCol: 0,
autoCheck: cfg.AutoCheck,
timerEnabled: cfg.TimerEnabled,
startTime: time.Now(),
flashes: map[[2]int]time.Time{},
notes: make(map[[2]int][]uint8),
}
return m
}
func solveCopy(g game.Grid) *game.Grid {
var sg solver.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
sg[r][c] = g[r][c]
}
}
if solver.Solve(&sg, 2*time.Second) {
var out game.Grid
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
out[r][c] = sg[r][c]
}
}
return &out
}
return nil
}
func (m Model) Init() tea.Cmd {
var cmds []tea.Cmd
if m.timerEnabled {
cmds = append(cmds, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} }))
}
return tea.Batch(cmds...)
}
func (m Model) View() string { return Render(m) }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
return m.handleKey(msg)
case timerTickMsg:
if m.timerEnabled && !m.completed {
m.elapsed = time.Since(m.startTime)
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
}
return m, nil
case flashDoneMsg:
delete(m.flashes, [2]int{msg.Row, msg.Col})
return m, nil
}
return m, nil
}
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := msg
// Help overlay toggle
if key.Matches(k, m.keymap.Help) {
m.showHelp = !m.showHelp
return m, nil
}
// When help is shown, only allow closing it
if m.showHelp {
return m, nil
}
// Pause toggle
if key.Matches(k, m.keymap.Pause) {
m.paused = !m.paused
return m, nil
}
// When paused, only allow unpause or quit
if m.paused {
if k.String() == "q" || k.String() == "esc" || k.String() == "ctrl+c" {
return m, tea.Quit
}
return m, nil
}
// Toggle features
if key.Matches(k, m.keymap.ToggleAuto) {
m.autoCheck = !m.autoCheck
return m, nil
}
if key.Matches(k, m.keymap.ToggleTimer) {
m.timerEnabled = !m.timerEnabled
if m.timerEnabled && !m.completed {
m.startTime = time.Now().Add(-m.elapsed)
return m, tea.Tick(time.Second, func(time.Time) tea.Msg { return timerTickMsg{} })
}
return m, nil
}
if key.Matches(k, m.keymap.ToggleNote) {
m.noteMode = !m.noteMode
return m, nil
}
// Hint
if key.Matches(k, m.keymap.Hint) && !m.completed {
m = m.applyHint()
return m, nil
}
// Undo/Redo
if key.Matches(k, m.keymap.Undo) {
m = m.applyUndo()
return m, nil
}
if key.Matches(k, m.keymap.Redo) {
m = m.applyRedo()
return m, nil
}
// Save/Load
if key.Matches(k, m.keymap.Save) && !m.completed {
_ = m.saveGame() // Ignore errors for now
return m, nil
}
if key.Matches(k, m.keymap.Load) {
if loadedModel, err := m.loadGame(); err == nil {
m = loadedModel
}
return m, nil
}
s := k.String()
switch s {
case "up", "k":
m.cursorRow = clamp(m.cursorRow-1, 0, 8)
case "down", "j":
m.cursorRow = clamp(m.cursorRow+1, 0, 8)
case "left", "h":
m.cursorCol = clamp(m.cursorCol-1, 0, 8)
case "right", "l":
m.cursorCol = clamp(m.cursorCol+1, 0, 8)
case " ", "0":
return m.applyInput(0)
case "1", "2", "3", "4", "5", "6", "7", "8", "9":
v := uint8(s[0] - '0')
return m.applyInput(v)
case "q", "esc", "ctrl+c":
return m, tea.Quit
}
return m, nil
}
func (m Model) applyInput(v uint8) (tea.Model, tea.Cmd) {
if m.board.IsGiven(m.cursorRow, m.cursorCol) {
return m, nil
}
// Note mode: toggle candidate number
if m.noteMode && v != 0 {
key := [2]int{m.cursorRow, m.cursorCol}
notes := m.notes[key]
// Toggle the note
found := false
for i, n := range notes {
if n == v {
// Remove note
notes = append(notes[:i], notes[i+1:]...)
found = true
break
}
}
if !found {
// Add note
notes = append(notes, v)
}
if len(notes) > 0 {
m.notes[key] = notes
} else {
delete(m.notes, key)
}
return m, nil
}
// Normal mode: set value
prev, ok := m.board.SetValue(m.cursorRow, m.cursorCol, v)
if !ok {
return m, nil
}
// Clear notes for this cell when setting a value
if v != 0 {
delete(m.notes, [2]int{m.cursorRow, m.cursorCol})
}
mv := game.Move{Row: m.cursorRow, Col: m.cursorCol, Prev: prev, Next: v, At: time.Now()}
m.undoStack = append(m.undoStack, mv)
m.redoStack = nil
m.flashes[[2]int{m.cursorRow, m.cursorCol}] = time.Now().Add(120 * time.Millisecond)
if isSolved(m.board.Values, m.solution) {
m.completed = true
m.showWinAnim = true
m.winAnimStart = time.Now()
}
return m, tea.Tick(130*time.Millisecond, func(time.Time) tea.Msg { return flashDoneMsg{Row: mv.Row, Col: mv.Col} })
}
func (m Model) applyUndo() Model {
if len(m.undoStack) == 0 {
return m
}
last := m.undoStack[len(m.undoStack)-1]
m.undoStack = m.undoStack[:len(m.undoStack)-1]
m.board.Values[last.Row][last.Col] = last.Prev
m.redoStack = append(m.redoStack, last)
m.cursorRow, m.cursorCol = last.Row, last.Col
m.completed = isSolved(m.board.Values, m.solution)
return m
}
func (m Model) applyRedo() Model {
if len(m.redoStack) == 0 {
return m
}
last := m.redoStack[len(m.redoStack)-1]
m.redoStack = m.redoStack[:len(m.redoStack)-1]
m.board.Values[last.Row][last.Col] = last.Next
m.undoStack = append(m.undoStack, last)
m.cursorRow, m.cursorCol = last.Row, last.Col
m.completed = isSolved(m.board.Values, m.solution)
return m
}
func (m Model) applyHint() Model {
// Find first empty cell and fill it with solution value
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if !m.board.Given[r][c] && m.board.Values[r][c] == 0 {
solutionVal := m.solution[r][c]
if solutionVal != 0 {
m.board.Values[r][c] = solutionVal
m.cursorRow, m.cursorCol = r, c
m.hintsUsed++
m.flashes[[2]int{r, c}] = time.Now().Add(500 * time.Millisecond)
// Clear notes for hinted cell
delete(m.notes, [2]int{r, c})
if isSolved(m.board.Values, m.solution) {
m.completed = true
m.showWinAnim = true
m.winAnimStart = time.Now()
}
return m
}
}
}
}
return m
}
func (m Model) saveGame() error {
// Convert notes map to string keys for JSON
notesJSON := make(map[string][]uint8)
for key, notes := range m.notes {
keyStr := strconv.Itoa(key[0]) + "," + strconv.Itoa(key[1])
notesJSON[keyStr] = notes
}
sg := savegame.SavedGame{
Board: m.board.Values,
Solution: m.solution,
Given: m.board.Given,
Difficulty: m.difficulty,
Elapsed: int64(m.elapsed.Seconds()),
StartTime: m.startTime,
HintsUsed: m.hintsUsed,
Notes: notesJSON,
SavedAt: time.Now(),
AutoCheck: m.autoCheck,
TimerEnabled: m.timerEnabled,
}
return savegame.Save(sg)
}
func (m Model) loadGame() (Model, error) {
sg, err := savegame.Load()
if err != nil {
return m, err
}
// Restore board
m.board.Values = sg.Board
m.board.Given = sg.Given
m.solution = sg.Solution
m.difficulty = sg.Difficulty
m.hintsUsed = sg.HintsUsed
m.autoCheck = sg.AutoCheck
m.timerEnabled = sg.TimerEnabled
// Restore notes (convert string keys back to [2]int)
m.notes = make(map[[2]int][]uint8)
for keyStr, notes := range sg.Notes {
// Parse "r,c" format
var r, c int
fmt.Sscanf(keyStr, "%d,%d", &r, &c)
m.notes[[2]int{r, c}] = notes
}
// Restore timer state
if m.timerEnabled {
m.elapsed = time.Duration(sg.Elapsed) * time.Second
m.startTime = time.Now().Add(-m.elapsed)
}
m.completed = isSolved(m.board.Values, m.solution)
return m, nil
}
func clamp(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func (m Model) StatusLine() string {
// Completed UI
if m.completed {
adaptiveColors := theme.NewAdaptiveColors(m.theme)
gradientColors := adaptiveColors.GetGradientColors()
completeGrad := gradientColors["complete"]
var completeText string
if m.timerEnabled {
secs := int(m.elapsed.Truncate(time.Second).Seconds())
mins := (secs / 60) % 100
s := secs % 60
timeStr := fmt.Sprintf("%02d:%02d", mins, s)
if m.hintsUsed > 0 {
completeText = fmt.Sprintf("✭ Clear %s (%d hints) ! Press 'm' for menu ✭", timeStr, m.hintsUsed)
} else {
completeText = fmt.Sprintf("✭ Clear %s ! Press 'm' for menu ✭", timeStr)
}
} else {
completeText = "✭ Clear! Press 'm' for menu ✭"
}
return gradientText(completeText, completeGrad[0], completeGrad[1])
}
// All filled but not solved → Try again
if allFilled(m.board.Values) && !isSolved(m.board.Values, m.solution) {
return m.styles.StatusError.Render("✭ Try again... ✭")
}
// Normal status (fixed width segments)
var parts []string
// Timer
if m.timerEnabled {
secs := int(m.elapsed.Truncate(time.Second).Seconds())
mins := (secs / 60) % 100
s := secs % 60
timeValue := fmt.Sprintf("%02d:%02d", mins, s)
parts = append(parts, m.styles.Status.Render("Timer: ")+m.styles.BoolTrue.Render(timeValue))
}
// Hints used
if m.hintsUsed > 0 {
parts = append(parts, m.styles.Status.Render(fmt.Sprintf("Hints: %d", m.hintsUsed)))
}
// Note mode indicator
if m.noteMode {
parts = append(parts, m.styles.BoolTrue.Render("NOTE MODE"))
}
// Help hint
parts = append(parts, m.styles.Status.Render("Help: ?"))
separator := m.styles.Status.Render(" | ")
return strings.Join(parts, separator)
}
func allFilled(g game.Grid) bool {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if g[r][c] == 0 {
return false
}
}
}
return true
}
func isSolved(cur game.Grid, sol game.Grid) bool {
for r := 0; r < 9; r++ {
for c := 0; c < 9; c++ {
if cur[r][c] != sol[r][c] {
return false
}
}
}
return true
}

97
internal/ui/keymap.go Normal file
View File

@@ -0,0 +1,97 @@
package ui
import (
"github.com/charmbracelet/bubbles/key"
"strings"
)
type KeyMap struct {
Up, Down, Left, Right key.Binding
Undo, Redo key.Binding
ToggleAuto key.Binding
ToggleTimer key.Binding
Help key.Binding
MainMenu key.Binding
Hint key.Binding
Pause key.Binding
ToggleNote key.Binding
Save key.Binding
Load key.Binding
}
func DefaultKeyMap() KeyMap {
return KeyMap{
Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "Up")),
Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "Down")),
Left: key.NewBinding(key.WithKeys("left", "h"), key.WithHelp("←/h", "Left")),
Right: key.NewBinding(key.WithKeys("right", "l"), key.WithHelp("→/l", "Right")),
Undo: key.NewBinding(key.WithKeys("ctrl+z", "u"), key.WithHelp("u/Ctrl+Z", "Undo")),
Redo: key.NewBinding(key.WithKeys("ctrl+y", "ctrl+r"), key.WithHelp("Ctrl+Y/R", "Redo")),
ToggleAuto: key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "Auto-Check")),
ToggleTimer: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "Timer")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "Help")),
MainMenu: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "Main Menu")),
Hint: key.NewBinding(key.WithKeys("ctrl+h"), key.WithHelp("Ctrl+H", "Hint")),
Pause: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "Pause")),
ToggleNote: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "Note Mode")),
Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("Ctrl+S", "Save")),
Load: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("Ctrl+L", "Load")),
}
}
func (km *KeyMap) ApplyBindings(bindings map[string][]string) {
if bindings == nil {
return
}
set := func(b *key.Binding, keys []string, help string) {
if len(keys) == 0 {
return
}
*b = key.NewBinding(key.WithKeys(keys...), key.WithHelp(strings.Join(keys, "/"), help))
}
if v, ok := bindings["up"]; ok {
set(&km.Up, v, "Up")
}
if v, ok := bindings["down"]; ok {
set(&km.Down, v, "Down")
}
if v, ok := bindings["left"]; ok {
set(&km.Left, v, "Left")
}
if v, ok := bindings["right"]; ok {
set(&km.Right, v, "Right")
}
if v, ok := bindings["undo"]; ok {
set(&km.Undo, v, "Undo")
}
if v, ok := bindings["redo"]; ok {
set(&km.Redo, v, "Redo")
}
if v, ok := bindings["auto"]; ok {
set(&km.ToggleAuto, v, "Auto-Check")
}
if v, ok := bindings["timer"]; ok {
set(&km.ToggleTimer, v, "Timer")
}
if v, ok := bindings["help"]; ok {
set(&km.Help, v, "Help")
}
if v, ok := bindings["main"]; ok {
set(&km.MainMenu, v, "Main Menu")
}
if v, ok := bindings["hint"]; ok {
set(&km.Hint, v, "Hint")
}
if v, ok := bindings["pause"]; ok {
set(&km.Pause, v, "Pause")
}
if v, ok := bindings["note"]; ok {
set(&km.ToggleNote, v, "Note Mode")
}
if v, ok := bindings["save"]; ok {
set(&km.Save, v, "Save")
}
if v, ok := bindings["load"]; ok {
set(&km.Load, v, "Load")
}
}

634
internal/ui/menu.go Normal file
View File

@@ -0,0 +1,634 @@
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)
}

287
internal/ui/profile.go Normal file
View File

@@ -0,0 +1,287 @@
package ui
import (
"fmt"
"strings"
"termdoku/internal/database"
"termdoku/internal/stats"
"termdoku/internal/theme"
"github.com/charmbracelet/lipgloss"
)
// viewProfile displays a simple profile landing page with navigation options.
func (a App) viewProfile() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
profileGrad := gradientColors["banner"]
title := gradientText("User Profile", profileGrad[0], profileGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
content.WriteString(a.renderProfileSummary())
content.WriteString("\n\n")
instructionStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(a.th.Palette.Accent)).
Bold(true)
content.WriteString(instructionStyle.Render("Navigation:") + "\n")
content.WriteString(a.styles.Status.Render(" Press 's' to view detailed profile data (Stats, Achievements, Leaderboard)"))
content.WriteString("\n")
content.WriteString(a.styles.Status.Render(" Press 'd' to view database info"))
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)
}
// viewProfileSubmenu displays a dropdown menu for selecting profile data type.
func (a App) viewProfileSubmenu() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
profileGrad := gradientColors["banner"]
title := gradientText(">> Select Profile Data", profileGrad[0], profileGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
var items []string
accentColors := adaptiveColors.GetAccentColors()
selectedStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(accentColors["selected"])).
Bold(true)
for i, name := range a.profileMenuItems {
if i == a.profileSelectedIdx {
prefix := "▶ "
label := prefix + name
items = append(items, selectedStyle.Render(label))
} else {
prefix := " "
label := prefix + name
items = append(items, a.styles.MenuItem.Render(label))
}
}
menuContent := strings.Join(items, "\n")
menuStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(profileGrad[0])).
Padding(1, 2).
MarginTop(1).
MarginBottom(1)
content.WriteString(menuStyle.Render(menuContent))
content.WriteString("\n\n")
content.WriteString(a.styles.Status.Render("Use ↑/↓ or k/j to navigate, Enter to select"))
content.WriteString("\n")
content.WriteString(a.styles.Status.Render("Press 'm' or Esc to return to profile"))
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)
}
// renderProfileSummary renders the user summary card.
func (a App) renderProfileSummary() string {
var sb strings.Builder
totalGames := a.stats.TotalGames
completed := a.stats.CompletedGames
winRate := 0.0
if totalGames > 0 {
winRate = float64(completed) / float64(totalGames) * 100
}
rank := a.calculateRank(completed)
rankEmoji := a.getRankEmoji(rank)
summaryStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(a.th.Palette.Accent)).
Padding(1, 2).
MarginTop(1)
summaryContent := fmt.Sprintf(
"%s Rank: %s"+
"\n>> Total Games: %d"+
"\n>> Completed: %d"+
"\n>> Win Rate: %.1f%%"+
"\n>> Current Streak: %d days"+
"\n>> Best Streak: %d days",
rankEmoji, rank,
totalGames,
completed,
winRate,
a.stats.CurrentStreak,
a.stats.BestStreak,
)
sb.WriteString(summaryStyle.Render(summaryContent))
return sb.String()
}
// calculateRank determines user rank based on completed games.
func (a App) calculateRank(completed int) string {
switch {
case completed >= 1000:
return "Grandmaster"
case completed >= 500:
return "Master"
case completed >= 250:
return "Expert"
case completed >= 100:
return "Advanced"
case completed >= 50:
return "Intermediate"
case completed >= 25:
return "Apprentice"
case completed >= 10:
return "Novice"
case completed >= 1:
return "Beginner"
default:
return "Newcomer"
}
}
// getRankEmoji returns an emoji for the rank.
func (a App) getRankEmoji(rank string) string {
switch rank {
case "Grandmaster":
return "👑"
case "Master":
return "🎖️"
case "Expert":
return "🏅"
case "Advanced":
return "⭐"
case "Intermediate":
return "🌟"
case "Apprentice":
return "✨"
case "Novice":
return "🔰"
case "Beginner":
return "🌱"
default:
return "👤"
}
}
// viewDatabaseInfo displays detailed database information.
func (a App) viewDatabaseInfo() string {
adaptiveColors := theme.NewAdaptiveColors(a.th)
gradientColors := adaptiveColors.GetGradientColors()
dbGrad := gradientColors["banner"]
title := gradientText("Database Info", dbGrad[0], dbGrad[1])
var content strings.Builder
content.WriteString(title + "\n\n")
db, err := database.Open()
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error opening database: %v\n", err)))
} else {
defer db.Close()
dbStats, err := db.GetStats()
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading stats: %v\n", err)))
} else {
statsStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color(a.th.Palette.Accent)).
Padding(1, 2)
statsContent := fmt.Sprintf(
"Total Games: %d"+
"\n>> Completed Games: %d"+
"\n>> Current Streak: %d"+
"\n>> Best Streak: %d"+
"\n>> Hints Used: %d"+
"\n>> Last Played: %s",
dbStats.TotalGames,
dbStats.CompletedGames,
dbStats.CurrentStreak,
dbStats.BestStreak,
dbStats.HintsUsed,
dbStats.LastPlayedDate,
)
content.WriteString(statsStyle.Render(statsContent))
}
content.WriteString("\n\n")
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(a.th.Palette.Accent)).
Render("Achievements")
content.WriteString(headerStyle + "\n")
achievements, err := db.GetAchievements()
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading achievements: %v\n", err)))
} else {
unlockedCount := 0
for _, ach := range achievements {
if ach.Unlocked {
unlockedCount++
}
}
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Unlocked: %d/%d\n", unlockedCount, len(achievements))))
}
content.WriteString("\n")
headerStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color(a.th.Palette.Accent)).
Render("Recent Games")
content.WriteString(headerStyle + "\n")
recentGames, err := db.GetRecentGames(5)
if err != nil {
content.WriteString(a.styles.Status.Render(fmt.Sprintf("Error reading games: %v\n", err)))
} else {
if len(recentGames) == 0 {
content.WriteString(a.styles.Status.Render("No games in database\n"))
} else {
for _, game := range recentGames {
statusIcon := "✅"
if !game.Completed {
statusIcon = "❌"
}
timeStr := stats.FormatTime(game.TimeSeconds)
line := fmt.Sprintf("%s %s - %s", statusIcon, game.Difficulty, timeStr)
if game.IsDaily {
line += " 📅"
}
content.WriteString(a.styles.Status.Render(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)
}

234
internal/ui/renderer.go Normal file
View 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()
}

78
internal/ui/styles.go Normal file
View File

@@ -0,0 +1,78 @@
package ui
import (
"termdoku/internal/theme"
"github.com/charmbracelet/lipgloss"
)
type UIStyles struct {
App lipgloss.Style
Panel lipgloss.Style
Banner lipgloss.Style
MenuItem lipgloss.Style
MenuItemSelected lipgloss.Style
Hint lipgloss.Style
BoolTrue lipgloss.Style
BoolFalse lipgloss.Style
Board lipgloss.Style
RowSep lipgloss.Style
ColSep lipgloss.Style
Cell lipgloss.Style
CellFixed lipgloss.Style
CellSelected lipgloss.Style
CellDuplicate lipgloss.Style
CellConflict lipgloss.Style
Status lipgloss.Style
StatusError lipgloss.Style
DiffBox lipgloss.Style
}
func BuildStyles(t theme.Theme) UIStyles {
gridColor := lipgloss.Color(t.Palette.GridLine)
accent := lipgloss.Color(t.Palette.Accent)
// Adaptive colors
adaptiveColors := theme.NewAdaptiveColors(t)
accentColors := adaptiveColors.GetAccentColors()
gray := lipgloss.Color("#9ca3af")
if t.Name == "light" {
gray = lipgloss.Color("#6b7280") // darker gray for light theme
}
menuItemColor := lipgloss.Color(t.Palette.Foreground)
statusColor := gray
if t.Name == "light" {
menuItemColor = lipgloss.Color("#000000")
statusColor = menuItemColor
}
return UIStyles{
App: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Palette.Foreground)),
Panel: lipgloss.NewStyle().Padding(0, 4).Margin(1, 4).Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color(accentColors["panel"])),
Banner: lipgloss.NewStyle().Foreground(accent).Bold(true),
MenuItem: lipgloss.NewStyle().Foreground(menuItemColor),
MenuItemSelected: lipgloss.NewStyle().Foreground(accent).Bold(true),
Hint: lipgloss.NewStyle().Foreground(accent),
BoolTrue: lipgloss.NewStyle().Foreground(lipgloss.Color("#16a34a")).Bold(true),
BoolFalse: lipgloss.NewStyle().Foreground(gray),
Board: lipgloss.NewStyle(),
RowSep: lipgloss.NewStyle().Foreground(gridColor),
ColSep: lipgloss.NewStyle().Foreground(gridColor),
Cell: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellBaseBG)).Foreground(lipgloss.Color(t.Palette.CellBaseFG)).Padding(0, 1),
CellFixed: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellFixedBG)).Foreground(lipgloss.Color(t.Palette.CellFixedFG)).Padding(0, 1).Bold(true),
CellSelected: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellSelectedBG)).Foreground(lipgloss.Color(t.Palette.CellSelectedFG)).Padding(0, 1).Bold(true),
CellDuplicate: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellDuplicateBG)).Padding(0, 1),
CellConflict: lipgloss.NewStyle().Background(lipgloss.Color(t.Palette.CellConflictBG)).Padding(0, 1).Bold(true),
Status: lipgloss.NewStyle().Foreground(statusColor),
StatusError: lipgloss.NewStyle().Foreground(lipgloss.Color(accentColors["error"])).Bold(true),
DiffBox: lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(accent).Padding(1, 4),
}
}