(Feat): Initial Commit, Termdoku
This commit is contained in:
16
internal/ui/assets/banner.txt
Normal file
16
internal/ui/assets/banner.txt
Normal file
@@ -0,0 +1,16 @@
|
||||
,----,
|
||||
,/ .`|
|
||||
,` .' : ____ ,---, ,-.
|
||||
; ; / ,' , `. .' .' `\ ,--/ /|
|
||||
.'___,/ ,' __ ,-. ,-+-,.' _ |,---.' \ ,---. ,--. :/ | ,--,
|
||||
| : | ,' ,'/ /| ,-+-. ; , ||| | .`\ | ' ,'\ : : ' / ,'_ /|
|
||||
; |.'; ; ,---. ' | |' | ,--.'|' | ||: : | ' | / / || ' / .--. | | :
|
||||
`----' | | / \ | | ,'| | ,', | |,| ' ' ; :. ; ,. :' | : ,'_ /| : . |
|
||||
' : ; / / |' : / | | / | |--' ' | ; . |' | |: :| | \ | ' | | . .
|
||||
| | '. ' / || | ' | : | | , | | : | '' | .; :' : |. \ | | ' | | |
|
||||
' : |' ; /|; : | | : | |/ ' : | / ; | : || | ' \ \: | : ; ; |
|
||||
; |.' ' | / || , ; | | |`-' | | '` ,/ \ \ / ' : |--' ' : `--' \
|
||||
'---' | : | ---' | ;/ ; : .' `----' ; |,' : , .-./
|
||||
\ \ / '---' | ,.' '--' `--`----'
|
||||
`----' '---'
|
||||
|
||||
20
internal/ui/example.go
Normal file
20
internal/ui/example.go
Normal 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
473
internal/ui/game.go
Normal 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
97
internal/ui/keymap.go
Normal 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
634
internal/ui/menu.go
Normal 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
287
internal/ui/profile.go
Normal 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
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()
|
||||
}
|
||||
78
internal/ui/styles.go
Normal file
78
internal/ui/styles.go
Normal 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),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user