diff --git a/internal/game/game.go b/internal/game/game.go index 8b22702..c5670eb 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -15,36 +15,43 @@ type Stats struct { Mistakes int // total wrong keystrokes, including corrected ones } +type RacePoint struct { + MS int `json:"ms"` + Len int `json:"len"` +} + type Game struct { - text string - Snippet lang.Snippet - input string - errors map[int]bool // unfixed errors — used for live display coloring - mistakeAt map[int]bool // every wrong keystroke ever — never cleared by backspace - started bool - duration int - CodeMode bool // true = snippet-based typing, false = standard words + text string + Snippet lang.Snippet + input string + errors map[int]bool // unfixed errors — used for live display coloring + mistakeAt map[int]bool // every wrong keystroke ever — never cleared by backspace + started bool + duration int + CodeMode bool // true = snippet-based typing, false = standard words difficulty string elapsed time.Duration lastTyped time.Time LastTick time.Time + raceTrack []RacePoint } // Accessors for fields that TUI needs to read -func (g *Game) Text() string { return g.text } -func (g *Game) Input() string { return g.input } -func (g *Game) Errors() map[int]bool { return g.errors } -func (g *Game) Started() bool { return g.started } -func (g *Game) Duration() int { return g.duration } +func (g *Game) Text() string { return g.text } +func (g *Game) Input() string { return g.input } +func (g *Game) Errors() map[int]bool { return g.errors } +func (g *Game) Started() bool { return g.started } +func (g *Game) Duration() int { return g.duration } func (g *Game) Elapsed() time.Duration { return g.elapsed } -func (g *Game) SetText(s string) { g.text = normalizeTabs(s) } +func (g *Game) SetText(s string) { g.text = normalizeTabs(s) } +func (g *Game) RaceTrack() []RacePoint { return g.raceTrack } func New(duration int, mode string, language string, difficulty string) *Game { g := &Game{ - duration: duration, // 0 means infinite mode (tied to length of snippet) - errors: make(map[int]bool), - mistakeAt: make(map[int]bool), + duration: duration, // 0 means infinite mode (tied to length of snippet) + errors: make(map[int]bool), + mistakeAt: make(map[int]bool), difficulty: difficulty, } @@ -138,9 +145,9 @@ func (g *Game) TypeChar(ch rune) { break } } + g.recordRacePoint() } - func (g *Game) Backspace() { if len(g.input) == 0 { return @@ -169,6 +176,7 @@ func (g *Game) Backspace() { delete(g.errors, pos) // mistakeAt is NOT cleared — tracks lifetime errors } + g.recordRacePoint() } func (g *Game) TimeLeft() int { @@ -289,4 +297,16 @@ func (g *Game) Reset(mode string, language string, difficulty string) { g.elapsed = 0 g.lastTyped = time.Time{} g.LastTick = time.Time{} + g.raceTrack = nil +} + +func (g *Game) recordRacePoint() { + if !g.started { + return + } + ms := int(g.elapsed.Milliseconds()) + g.raceTrack = append(g.raceTrack, RacePoint{ + MS: ms, + Len: len(g.input), + }) } diff --git a/internal/game/storage.go b/internal/game/storage.go index 818fb56..702c1b5 100644 --- a/internal/game/storage.go +++ b/internal/game/storage.go @@ -2,6 +2,7 @@ package game import ( "bufio" + "encoding/json" "fmt" "os" "path/filepath" @@ -12,6 +13,18 @@ import ( var dataDir string +type RaceRecord struct { + ID string `json:"id"` + At string `json:"at"` + Duration int `json:"duration"` + Mode string `json:"mode"` + Language string `json:"language"` + Difficulty string `json:"difficulty"` + Text string `json:"text"` + Stats Stats `json:"stats"` + Points []RacePoint `json:"points"` +} + func init() { configDir, _ := os.UserConfigDir() dataDir = filepath.Join(configDir, "toofan") @@ -100,6 +113,58 @@ func SavePB(duration int, mode string, wpm float64) { } } +func SaveRace(r RaceRecord) { + os.MkdirAll(dataDir, 0755) + path := filepath.Join(dataDir, "races.txt") + + races := LoadRaces() + races = append(races, r) + if len(races) > 10 { + races = races[len(races)-10:] + } + + f, err := os.Create(path) + if err != nil { + return + } + defer f.Close() + + for _, rec := range races { + b, err := json.Marshal(rec) + if err != nil { + continue + } + fmt.Fprintln(f, string(b)) + } +} + +func LoadRaces() []RaceRecord { + path := filepath.Join(dataDir, "races.txt") + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var out []RaceRecord + scanner := bufio.NewScanner(f) + for scanner.Scan() { + var r RaceRecord + if err := json.Unmarshal([]byte(scanner.Text()), &r); err != nil { + continue + } + if r.Text == "" || len(r.Points) == 0 { + continue + } + out = append(out, r) + } + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + + func LoadConfig() (duration int, mode string, language string, difficulty string, themeName string) { duration, mode, language, difficulty, themeName = 30, "words", "go", "easy", "tokyonight" @@ -176,7 +241,7 @@ func SaveBackup() (string, error) { dest := filepath.Join(backupDir, fmt.Sprintf("toofan_backup_%s.txt", stamp)) var bundle strings.Builder - for _, name := range []string{"results.txt", "pb.txt", "config.txt"} { + for _, name := range []string{"results.txt", "pb.txt", "config.txt", "races.txt"} { data, err := os.ReadFile(filepath.Join(dataDir, name)) if err != nil { continue diff --git a/internal/tui/model.go b/internal/tui/model.go index 949d04b..b936ee7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -22,26 +22,26 @@ const ( ) type model struct { - active screen - game *game.Game - duration int - mode string // "words" or "code" - lang string + active screen + game *game.Game + duration int + mode string // "words" or "code" + lang string difficulty string width, height int - pickingDur bool - durCur int - pickingLang bool - langCur int - pickingLesson bool - lessonCur int - pickingTheme bool - themeCur int + pickingDur bool + durCur int + pickingLang bool + langCur int + pickingLesson bool + lessonCur int + pickingTheme bool + themeCur int pickingDifficulty bool diffCur int - showHelp bool + showHelp bool result game.Stats pb float64 @@ -57,6 +57,11 @@ type model struct { pickingRestore bool backups []string restoreCur int + + pickingRace bool + raceCur int + races []game.RaceRecord + activeRace *game.RaceRecord } func New() model { @@ -64,10 +69,10 @@ func New() model { theme.Current = theme.ByName(th) return model{ - game: game.New(duration, mode, language, difficulty), - duration: duration, - mode: mode, - lang: language, + game: game.New(duration, mode, language, difficulty), + duration: duration, + mode: mode, + lang: language, difficulty: difficulty, } } @@ -75,7 +80,7 @@ func New() model { type tick time.Time func (m model) isPaused() bool { - return m.pickingDur || m.pickingLang || m.pickingLesson || m.pickingTheme || m.showHelp + return m.pickingDur || m.pickingLang || m.pickingLesson || m.pickingTheme || m.showHelp || m.pickingRace } func (m model) Init() tea.Cmd { @@ -107,6 +112,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { durToSave = m.game.TimeLeft() } game.SaveResult(m.result, durToSave, m.mode, m.lang) + game.SaveRace(game.RaceRecord{ + ID: time.Now().Format("20060102150405.000000000"), + At: time.Now().Format("2006-01-02 15:04"), + Duration: durToSave, + Mode: m.mode, + Language: m.lang, + Difficulty: m.difficulty, + Text: m.game.Text(), + Stats: m.result, + Points: m.game.RaceTrack(), + }) + if m.gotNewPB { game.SavePB(m.duration, m.mode, m.result.WPM) } @@ -148,6 +165,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + if m.pickingRace { + return m.handleRacePicker(msg) + } switch msg.String() { case "ctrl+c": @@ -189,6 +209,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": m.duration = durations[m.durCur] m.pickingDur = false + m.activeRace = nil m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) m.save() return m, nil @@ -215,6 +236,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": m.difficulty = difficulties[m.diffCur] m.pickingDifficulty = false + m.activeRace = nil m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) m.save() return m, nil @@ -262,6 +284,8 @@ func (m model) View() string { names = append(names, filepath.Base(f)) } body = renderList(p, "restore backup", names, nil, m.restoreCur) + } else if m.pickingRace { + body = m.viewRacePicker(p) } else { switch m.active { case screenTyping: diff --git a/internal/tui/picker.go b/internal/tui/picker.go index c4d4f77..3a532c0 100644 --- a/internal/tui/picker.go +++ b/internal/tui/picker.go @@ -1,6 +1,7 @@ package tui import ( + "fmt" "strings" tea "github.com/charmbracelet/bubbletea" @@ -25,6 +26,7 @@ func (m model) handlePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "enter": m.lang = lang.Names[m.langCur] m.pickingLang = false + m.activeRace = nil m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) m.save() case "esc": @@ -54,6 +56,7 @@ func (m model) handleLessonPicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "enter": m.pickingLesson = false + m.activeRace = nil m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) if m.lessonCur > 0 && m.lessonCur <= len(snippets) { m.game.Snippet = snippets[m.lessonCur-1] @@ -134,6 +137,56 @@ func (m model) viewDifficultyPicker(p theme.Palette) string { return renderList(p, "difficulty", difficulties, nil, m.diffCur) } +func (m model) handleRacePicker(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k": + if m.raceCur > 0 { + m.raceCur-- + } + case "down", "j": + if m.raceCur < len(m.races)-1 { + m.raceCur++ + } + case "enter": + if len(m.races) > 0 { + r := m.races[m.raceCur] + m.activeRace = &r + m.duration = r.Duration + m.mode = r.Mode + m.lang = r.Language + m.difficulty = r.Difficulty + m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) + m.game.SetText(r.Text) + m.save() + } + m.pickingRace = false + case "esc": + m.pickingRace = false + } + return m, nil +} + +func (m model) viewRacePicker(p theme.Palette) string { + if len(m.races) == 0 { + return renderList(p, "race history", []string{"no races"}, nil, 0) + } + + names := make([]string, len(m.races)) + for i, r := range m.races { + names[i] = fmt.Sprintf("%s · %s · %ds · %.0f wpm · %.0f%%", + r.At, raceModeLabel(r), r.Duration, r.Stats.WPM, r.Stats.Accuracy, + ) + } + return renderList(p, "race history", names, nil, m.raceCur) +} + +func raceModeLabel(r game.RaceRecord) string { + if r.Mode == "code" { + return "code:" + r.Language + } + return "words" +} + // clean list — no borders, just highlighted selection, padded uniformly func renderList(p theme.Palette, title string, items []string, suffixes []string, cur int) string { dim := lipgloss.NewStyle().Foreground(p.Foreground) diff --git a/internal/tui/results.go b/internal/tui/results.go index 3f2521d..600c32c 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -40,6 +40,11 @@ func (m model) handleResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.durCur = i } } + m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) + if m.activeRace != nil { + m.game.SetText(m.activeRace.Text) + } + m.showingErrors = false m.active = screenTyping return m, nil } @@ -50,6 +55,9 @@ func (m model) handleResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) + if m.activeRace != nil { + m.game.SetText(m.activeRace.Text) + } m.showingErrors = false m.active = screenTyping return m, nil @@ -107,13 +115,19 @@ func (m model) viewResults(p theme.Palette) string { ) } - stats := lipgloss.JoinHorizontal(lipgloss.Top, + blocks := []string{ statBlock("wpm", hi.Render(fmt.Sprintf("%.0f", r.WPM))), + } + if m.activeRace != nil { + blocks = append(blocks, statBlock("old wpm", val.Render(fmt.Sprintf("%.0f", m.activeRace.Stats.WPM)))) + } + blocks = append(blocks, statBlock("acc", val.Render(fmt.Sprintf("%.0f%%", r.Accuracy))), statBlock("raw", val.Render(fmt.Sprintf("%.0f", r.Raw))), statBlock("typos", errStr), statBlock("time", val.Render(timeStr)), ) + stats := lipgloss.JoinHorizontal(lipgloss.Top, blocks...) var out []string out = append(out, "", stats, "", "") @@ -129,7 +143,6 @@ func (m model) viewResults(p theme.Palette) string { } else if m.pb > 0 { out = append(out, dim.Render(fmt.Sprintf("pb %.0f", m.pb))) } - return lipgloss.JoinVertical(lipgloss.Center, out...) } diff --git a/internal/tui/text.go b/internal/tui/text.go index 4c8e563..a748fac 100644 --- a/internal/tui/text.go +++ b/internal/tui/text.go @@ -9,10 +9,11 @@ import ( ) // colorText renders visible lines with typed/error/cursor/untyped styling -func colorText(g *game.Game, p theme.Palette, lines []string, top, bot int) string { +func colorText(g *game.Game, p theme.Palette, lines []string, top, bot int, ghostPos int) string { ok := lipgloss.NewStyle().Foreground(p.Typed) bad := lipgloss.NewStyle().Foreground(p.Error).Underline(true) cur := lipgloss.NewStyle().Foreground(p.Background).Background(p.Cursor) + ghost := lipgloss.NewStyle().Foreground(p.Accent).Underline(true) dim := lipgloss.NewStyle().Foreground(p.Foreground) typed := len(g.Input()) @@ -34,12 +35,14 @@ func colorText(g *game.Game, p theme.Palette, lines []string, top, bot int) stri for _, ch := range lines[i] { s := string(ch) switch { + case pos == typed: + out.WriteString(cur.Render(s)) + case pos == ghostPos: + out.WriteString(ghost.Render(s)) case pos < typed && g.Errors()[pos]: out.WriteString(bad.Render(s)) case pos < typed: out.WriteString(ok.Render(s)) - case pos == typed: - out.WriteString(cur.Render(s)) default: out.WriteString(dim.Render(s)) } diff --git a/internal/tui/typing.go b/internal/tui/typing.go index c4ff9b3..22b1337 100644 --- a/internal/tui/typing.go +++ b/internal/tui/typing.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -24,10 +25,14 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "tab": - m.game.Reset(m.mode, m.lang, m.difficulty) + m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) + if m.activeRace != nil { + m.game.SetText(m.activeRace.Text) + } case "ctrl+d": if m.mode == "words" { + m.activeRace = nil m.pickingDifficulty = true m.diffCur = 0 for i, d := range difficulties { @@ -40,6 +45,7 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+w": if !m.game.Started() { + m.activeRace = nil if m.mode == "words" { m.mode = "code" } else { @@ -51,6 +57,7 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+l": if m.mode == "code" && !m.game.Started() { + m.activeRace = nil m.pickingLang = true m.langCur = 0 for i, name := range lang.Names { @@ -62,14 +69,12 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "ctrl+o": if m.mode == "code" && !m.game.Started() { + m.activeRace = nil m.pickingLesson = true m.lessonCur = 0 } case "ctrl+t": - if m.game.Started() { - m.game.Reset(m.mode, m.lang, m.difficulty) - } m.pickingTheme = true m.themeCur = 0 for i, t := range theme.All { @@ -97,6 +102,18 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.active = screenProfile return m, nil } + case "ctrl+g": + if !m.game.Started() { + m.races = game.LoadRaces() + if len(m.races) == 0 { + m.message = "no saved races yet" + m.msgTime = time.Now() + return m, nil + } + m.raceCur = 0 + m.pickingRace = true + return m, nil + } case "enter": m.game.TypeChar('\n') @@ -141,6 +158,10 @@ func (m model) viewTyping(p theme.Palette) string { lines := splitLines(m.game.Text(), textWidth, m.game.CodeMode) curLine := cursorLine(lines, len(m.game.Input())) + ghostPos := -1 + if m.activeRace != nil && m.game.Started() { + ghostPos = ghostLenAt(m.activeRace.Points, int(m.game.Elapsed().Milliseconds())) + } // word mode: 3 lines (monkeytype style), code mode: 7 lines (full snippet) visible := 3 @@ -158,7 +179,7 @@ func (m model) viewTyping(p theme.Palette) string { text := lipgloss.NewStyle(). Padding(0, 2). - Render(colorText(m.game, p, lines, top, bot)) + Render(colorText(m.game, p, lines, top, bot, ghostPos)) // timer at top var topLine string @@ -170,6 +191,7 @@ func (m model) viewTyping(p theme.Palette) string { } else { topLine = hi.Render(fmt.Sprintf("%d", timeLeft)) } + } else { if m.duration == 0 { topLine = hi.Render("∞") @@ -214,7 +236,11 @@ func (m model) viewTyping(p theme.Palette) string { } else { modeLabel = "words" } - info := dim.Render(modeLabel + " · ? help") + infoStr := modeLabel + " · ? help" + if m.activeRace != nil { + infoStr += fmt.Sprintf(" · old %.0f", m.activeRace.Stats.WPM) + } + info := dim.Render(infoStr) out = append(out, "", info) } @@ -237,6 +263,7 @@ func (m model) viewHelp(p theme.Palette) string { val.Render("ctrl+t") + dim.Render(" change theme"), val.Render("ctrl+p") + dim.Render(" open profile"), val.Render("ctrl+d") + dim.Render(" change difficulty (words mode only)"), + val.Render("ctrl+g") + dim.Render(" race against previous run"), val.Render("tab") + dim.Render(" restart test"), val.Render("esc") + dim.Render(" configure duration"), val.Render("e") + dim.Render(" view error words (results screen)"), @@ -247,3 +274,17 @@ func (m model) viewHelp(p theme.Palette) string { return lipgloss.JoinVertical(lipgloss.Left, lines...) } + +func ghostLenAt(points []game.RacePoint, elapsedMS int) int { + if len(points) == 0 { + return 0 + } + out := 0 + for _, p := range points { + if p.MS > elapsedMS { + break + } + out = p.Len + } + return out +}