474 lines
11 KiB
Go
474 lines
11 KiB
Go
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
|
|
}
|