From ace5a8917e852131d6c17b2e2df971cb61b6d2e1 Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Fri, 8 May 2026 01:57:49 +0530 Subject: [PATCH 1/9] add race against yourself feature to picker --- internal/tui/picker.go | 53 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) 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) From a92c7322fbef358a13a06845becdeb241e6a5e0a Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Fri, 8 May 2026 01:58:38 +0530 Subject: [PATCH 2/9] update `data types` to support racing against past versions of yourself --- internal/game/game.go | 70 +++++++++++++++++++++++++++++----------- internal/game/storage.go | 54 +++++++++++++++++++++++++++++++ internal/tui/model.go | 69 ++++++++++++++++++++++++++++----------- 3 files changed, 156 insertions(+), 37 deletions(-) diff --git a/internal/game/game.go b/internal/game/game.go index 8b22702..7cfa3e6 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 append([]RacePoint(nil), 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,30 @@ 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) elapsedAt(now time.Time) time.Duration { + if !g.started { + return 0 + } + if g.LastTick.IsZero() || g.Finished() { + return g.elapsed + } + dt := now.Sub(g.LastTick) + if dt < 0 { + dt = 0 + } + return g.elapsed + dt +} + +func (g *Game) recordRacePoint() { + if !g.started { + return + } + ms := int(g.elapsedAt(time.Now()).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..5f1a685 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,47 @@ func SavePB(duration int, mode string, wpm float64) { } } +func SaveRace(r RaceRecord) { + os.MkdirAll(dataDir, 0755) + f, err := os.OpenFile( + filepath.Join(dataDir, "races.txt"), + os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644, + ) + if err != nil { + return + } + defer f.Close() + + b, err := json.Marshal(r) + if err != nil { + return + } + _, _ = 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) + } + return out +} + func LoadConfig() (duration int, mode string, language string, difficulty string, themeName string) { duration, mode, language, difficulty, themeName = 30, "words", "go", "easy", "tokyonight" diff --git a/internal/tui/model.go b/internal/tui/model.go index 949d04b..cf49f12 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,12 @@ type model struct { pickingRestore bool backups []string restoreCur int + + pickingRace bool + raceCur int + races []game.RaceRecord + activeRace *game.RaceRecord + raceDelta game.Stats } func New() model { @@ -64,10 +70,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 +81,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 +113,24 @@ 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(), + }) + m.raceDelta = game.Stats{} + if m.activeRace != nil { + m.raceDelta.WPM = m.result.WPM - m.activeRace.Stats.WPM + m.raceDelta.Accuracy = m.result.Accuracy - m.activeRace.Stats.Accuracy + m.raceDelta.Raw = m.result.Raw - m.activeRace.Stats.Raw + m.raceDelta.Mistakes = m.result.Mistakes - m.activeRace.Stats.Mistakes + } if m.gotNewPB { game.SavePB(m.duration, m.mode, m.result.WPM) } @@ -148,6 +172,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 +216,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 +243,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 +291,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: From dcc72fb168470c4ca789cb4930e9568a73d6d50f Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Fri, 8 May 2026 01:59:23 +0530 Subject: [PATCH 3/9] update results page to show results when racing against yourself is complete --- internal/tui/results.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/tui/results.go b/internal/tui/results.go index 6eafff8..5686302 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -25,6 +25,7 @@ func (m model) handleResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case "tab": m.duration = nextDur(m.duration) + m.activeRace = nil m.save() case "ctrl+t": theme.Next() @@ -42,6 +43,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 @@ -121,6 +125,9 @@ func (m model) viewResults(p theme.Palette) string { } else if m.pb > 0 { out = append(out, dim.Render(fmt.Sprintf("pb %.0f", m.pb))) } + if m.activeRace != nil { + out = append(out, dim.Render(fmt.Sprintf("old race wpm %.0f", m.activeRace.Stats.WPM))) + } return lipgloss.JoinVertical(lipgloss.Center, out...) } From e60a8232a1bff694774c9ac028efb48f780fabeb Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Fri, 8 May 2026 01:59:53 +0530 Subject: [PATCH 4/9] add ghost texts and logic for starting races and handling the same --- internal/tui/text.go | 5 +++- internal/tui/typing.go | 53 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 3 deletions(-) diff --git a/internal/tui/text.go b/internal/tui/text.go index 4c8e563..259ec8e 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()) @@ -40,6 +41,8 @@ func colorText(g *game.Game, p theme.Palette, lines []string, top, bot int) stri out.WriteString(ok.Render(s)) case pos == typed: out.WriteString(cur.Render(s)) + case pos == ghostPos: + out.WriteString(ghost.Render(s)) default: out.WriteString(dim.Render(s)) } diff --git a/internal/tui/typing.go b/internal/tui/typing.go index 56f468f..2ca252d 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" @@ -14,7 +15,10 @@ import ( func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": - 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 "tab": m.pickingDur = true @@ -28,6 +32,7 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { 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,6 +69,7 @@ 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 } @@ -97,6 +105,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 = len(m.races) - 1 + m.pickingRace = true + return m, nil + } case "enter": m.game.TypeChar('\n') @@ -141,6 +161,13 @@ 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())) + if ghostPos < len(m.game.Input()) { + ghostPos = -1 + } + } // word mode: 3 lines (monkeytype style), code mode: 7 lines (full snippet) visible := 3 @@ -158,7 +185,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 +197,9 @@ func (m model) viewTyping(p theme.Palette) string { } else { topLine = hi.Render(fmt.Sprintf("%d", timeLeft)) } + if m.activeRace != nil { + topLine += dim.Render(fmt.Sprintf(" old %.0f wpm", m.activeRace.Stats.WPM)) + } } else { if m.duration == 0 { topLine = hi.Render("∞") @@ -216,6 +246,10 @@ func (m model) viewTyping(p theme.Palette) string { } info := dim.Render(modeLabel + " · ? help") out = append(out, "", info) + if m.activeRace != nil { + tag := fmt.Sprintf("racing old %.0f wpm", m.activeRace.Stats.WPM) + out = append(out, dim.Render(tag)) + } } body := lipgloss.JoinVertical(lipgloss.Center, out...) @@ -237,6 +271,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(" change duration & restart"), val.Render("esc") + dim.Render(" restart test immediately"), val.Render("e") + dim.Render(" view error words (results screen)"), @@ -247,3 +282,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 +} From 144e868196f56d24bf864939c430b4a3190d6d72 Mon Sep 17 00:00:00 2001 From: Aarav Maloo Date: Fri, 8 May 2026 02:08:20 +0530 Subject: [PATCH 5/9] update results to show `old wpm` as a normal stat block in top stats row --- internal/tui/results.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/internal/tui/results.go b/internal/tui/results.go index 5686302..78c226c 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -110,6 +110,12 @@ func (m model) viewResults(p theme.Palette) string { statBlock("typos", errStr), statBlock("time", val.Render(timeStr)), ) + if m.activeRace != nil { + stats = lipgloss.JoinHorizontal(lipgloss.Top, + stats, + statBlock("old wpm", dim.Render(fmt.Sprintf("%.0f", m.activeRace.Stats.WPM))), + ) + } var out []string out = append(out, "", stats, "", "") @@ -125,10 +131,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))) } - if m.activeRace != nil { - out = append(out, dim.Render(fmt.Sprintf("old race wpm %.0f", m.activeRace.Stats.WPM))) - } - return lipgloss.JoinVertical(lipgloss.Center, out...) } From b015c4b528b3674f1ee01a0896b1c9c58efd07f1 Mon Sep 17 00:00:00 2001 From: Amit Date: Mon, 11 May 2026 14:38:32 +0530 Subject: [PATCH 6/9] remove dead code: raceDelta, normalizeRaceRecord, elapsedAt --- internal/game/game.go | 18 ++---------------- internal/game/storage.go | 21 --------------------- internal/tui/model.go | 9 +-------- 3 files changed, 3 insertions(+), 45 deletions(-) diff --git a/internal/game/game.go b/internal/game/game.go index 7cfa3e6..c5670eb 100644 --- a/internal/game/game.go +++ b/internal/game/game.go @@ -45,7 +45,7 @@ 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) RaceTrack() []RacePoint { return append([]RacePoint(nil), g.raceTrack...) } +func (g *Game) RaceTrack() []RacePoint { return g.raceTrack } func New(duration int, mode string, language string, difficulty string) *Game { g := &Game{ @@ -300,25 +300,11 @@ func (g *Game) Reset(mode string, language string, difficulty string) { g.raceTrack = nil } -func (g *Game) elapsedAt(now time.Time) time.Duration { - if !g.started { - return 0 - } - if g.LastTick.IsZero() || g.Finished() { - return g.elapsed - } - dt := now.Sub(g.LastTick) - if dt < 0 { - dt = 0 - } - return g.elapsed + dt -} - func (g *Game) recordRacePoint() { if !g.started { return } - ms := int(g.elapsedAt(time.Now()).Milliseconds()) + 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 7a2c6fc..1beac95 100644 --- a/internal/game/storage.go +++ b/internal/game/storage.go @@ -146,7 +146,6 @@ func LoadRaces() []RaceRecord { if err := json.Unmarshal([]byte(scanner.Text()), &r); err != nil { continue } - normalizeRaceRecord(&r) if r.Text == "" || len(r.Points) == 0 { continue } @@ -155,26 +154,6 @@ func LoadRaces() []RaceRecord { return out } -func normalizeRaceRecord(r *RaceRecord) { - if strings.HasPrefix(r.Mode, "code:") { - if r.Language == "" { - r.Language = strings.TrimPrefix(r.Mode, "code:") - } - r.Mode = "code" - } - if r.Mode == "" { - r.Mode = "words" - } - if r.Mode != "code" { - r.Mode = "words" - } - if r.Language == "" { - r.Language = "go" - } - if r.Difficulty == "" { - r.Difficulty = "easy" - } -} func LoadConfig() (duration int, mode string, language string, difficulty string, themeName string) { duration, mode, language, difficulty, themeName = 30, "words", "go", "easy", "tokyonight" diff --git a/internal/tui/model.go b/internal/tui/model.go index cf49f12..b936ee7 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -62,7 +62,6 @@ type model struct { raceCur int races []game.RaceRecord activeRace *game.RaceRecord - raceDelta game.Stats } func New() model { @@ -124,13 +123,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Stats: m.result, Points: m.game.RaceTrack(), }) - m.raceDelta = game.Stats{} - if m.activeRace != nil { - m.raceDelta.WPM = m.result.WPM - m.activeRace.Stats.WPM - m.raceDelta.Accuracy = m.result.Accuracy - m.activeRace.Stats.Accuracy - m.raceDelta.Raw = m.result.Raw - m.activeRace.Stats.Raw - m.raceDelta.Mistakes = m.result.Mistakes - m.activeRace.Stats.Mistakes - } + if m.gotNewPB { game.SavePB(m.duration, m.mode, m.result.WPM) } From d35a3279e83b765c29f47eabe1f4629e28b1a236 Mon Sep 17 00:00:00 2001 From: Amit Date: Mon, 11 May 2026 14:40:01 +0530 Subject: [PATCH 7/9] fix: restore tab/esc keybinds to match master behavior --- internal/tui/results.go | 18 ++++++++---------- internal/tui/typing.go | 17 ++++++++--------- 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/internal/tui/results.go b/internal/tui/results.go index c3a3837..63f05eb 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -24,9 +24,15 @@ func (m model) handleResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } case "tab": - m.duration = nextDur(m.duration) - m.activeRace = nil + // restart immediately after a finished test + case "ctrl+t": + theme.Next() m.save() + case "esc": + if m.showingErrors { + m.showingErrors = false + return m, nil + } m.pickingDur = true m.durCur = 0 for i, d := range durations { @@ -36,14 +42,6 @@ func (m model) handleResults(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.active = screenTyping return m, nil - case "ctrl+t": - theme.Next() - m.save() - case "esc": - if m.showingErrors { - m.showingErrors = false - return m, nil - } } if m.showingErrors { diff --git a/internal/tui/typing.go b/internal/tui/typing.go index a79de89..0854af0 100644 --- a/internal/tui/typing.go +++ b/internal/tui/typing.go @@ -15,13 +15,6 @@ import ( func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": - m.game = game.New(m.duration, m.mode, m.lang, m.difficulty) - if m.activeRace != nil { - m.game.SetText(m.activeRace.Text) - } - return m, nil - - case "tab": m.pickingDur = true m.durCur = 0 for i, d := range durations { @@ -31,6 +24,12 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case "tab": + 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 @@ -273,8 +272,8 @@ func (m model) viewHelp(p theme.Palette) string { 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(" change duration & restart"), - val.Render("esc") + dim.Render(" restart test immediately"), + val.Render("tab") + dim.Render(" restart test"), + val.Render("esc") + dim.Render(" configure duration"), val.Render("e") + dim.Render(" view error words (results screen)"), val.Render("?") + dim.Render(" show this help"), "", From eb49ff71c06ba03230e5f01344fcd09a1239bcad Mon Sep 17 00:00:00 2001 From: Amit Date: Mon, 11 May 2026 14:41:20 +0530 Subject: [PATCH 8/9] fix: old wpm next to wpm on results, keep ghost visible, ctrl+t no reset --- internal/tui/results.go | 14 +++++++------- internal/tui/typing.go | 10 +--------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/internal/tui/results.go b/internal/tui/results.go index 63f05eb..d17adf0 100644 --- a/internal/tui/results.go +++ b/internal/tui/results.go @@ -110,19 +110,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)), ) - if m.activeRace != nil { - stats = lipgloss.JoinHorizontal(lipgloss.Top, - stats, - statBlock("old wpm", dim.Render(fmt.Sprintf("%.0f", m.activeRace.Stats.WPM))), - ) - } + stats := lipgloss.JoinHorizontal(lipgloss.Top, blocks...) var out []string out = append(out, "", stats, "", "") diff --git a/internal/tui/typing.go b/internal/tui/typing.go index 0854af0..64b23e4 100644 --- a/internal/tui/typing.go +++ b/internal/tui/typing.go @@ -75,9 +75,6 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } 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 { @@ -164,9 +161,6 @@ func (m model) viewTyping(p theme.Palette) string { ghostPos := -1 if m.activeRace != nil && m.game.Started() { ghostPos = ghostLenAt(m.activeRace.Points, int(m.game.Elapsed().Milliseconds())) - if ghostPos < len(m.game.Input()) { - ghostPos = -1 - } } // word mode: 3 lines (monkeytype style), code mode: 7 lines (full snippet) @@ -197,9 +191,7 @@ func (m model) viewTyping(p theme.Palette) string { } else { topLine = hi.Render(fmt.Sprintf("%d", timeLeft)) } - if m.activeRace != nil { - topLine += dim.Render(fmt.Sprintf(" old %.0f wpm", m.activeRace.Stats.WPM)) - } + } else { if m.duration == 0 { topLine = hi.Render("∞") From c14e9bc6beafc83f9c1b65922bc4940b1234edb8 Mon Sep 17 00:00:00 2001 From: Amit Date: Mon, 11 May 2026 14:43:10 +0530 Subject: [PATCH 9/9] fix: old wpm next to wpm on results, keep ghost visible, ctrl+t no reset, esc from results creates fresh game --- internal/game/storage.go | 28 +++++++++++++++++++--------- internal/tui/results.go | 5 +++++ internal/tui/text.go | 8 ++++---- internal/tui/typing.go | 10 +++++----- 4 files changed, 33 insertions(+), 18 deletions(-) diff --git a/internal/game/storage.go b/internal/game/storage.go index 1beac95..702c1b5 100644 --- a/internal/game/storage.go +++ b/internal/game/storage.go @@ -115,20 +115,27 @@ func SavePB(duration int, mode string, wpm float64) { func SaveRace(r RaceRecord) { os.MkdirAll(dataDir, 0755) - f, err := os.OpenFile( - filepath.Join(dataDir, "races.txt"), - os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644, - ) + 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() - b, err := json.Marshal(r) - if err != nil { - return + for _, rec := range races { + b, err := json.Marshal(rec) + if err != nil { + continue + } + fmt.Fprintln(f, string(b)) } - _, _ = fmt.Fprintln(f, string(b)) } func LoadRaces() []RaceRecord { @@ -151,6 +158,9 @@ func LoadRaces() []RaceRecord { } 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 } @@ -231,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/results.go b/internal/tui/results.go index d17adf0..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 } diff --git a/internal/tui/text.go b/internal/tui/text.go index 259ec8e..a748fac 100644 --- a/internal/tui/text.go +++ b/internal/tui/text.go @@ -35,14 +35,14 @@ func colorText(g *game.Game, p theme.Palette, lines []string, top, bot int, ghos for _, ch := range lines[i] { s := string(ch) switch { - 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)) 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)) default: out.WriteString(dim.Render(s)) } diff --git a/internal/tui/typing.go b/internal/tui/typing.go index 64b23e4..22b1337 100644 --- a/internal/tui/typing.go +++ b/internal/tui/typing.go @@ -110,7 +110,7 @@ func (m model) handleTyping(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.msgTime = time.Now() return m, nil } - m.raceCur = len(m.races) - 1 + m.raceCur = 0 m.pickingRace = true return m, nil } @@ -236,12 +236,12 @@ func (m model) viewTyping(p theme.Palette) string { } else { modeLabel = "words" } - info := dim.Render(modeLabel + " · ? help") - out = append(out, "", info) + infoStr := modeLabel + " · ? help" if m.activeRace != nil { - tag := fmt.Sprintf("racing old %.0f wpm", m.activeRace.Stats.WPM) - out = append(out, dim.Render(tag)) + infoStr += fmt.Sprintf(" · old %.0f", m.activeRace.Stats.WPM) } + info := dim.Render(infoStr) + out = append(out, "", info) } body := lipgloss.JoinVertical(lipgloss.Center, out...)