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 }