From 0b55bf4c28fd9eee747c28d43665c7f9115ad95d Mon Sep 17 00:00:00 2001 From: AbdelkaderBah Date: Sat, 7 Mar 2026 12:35:37 +0000 Subject: [PATCH 1/3] Add visual UI for finger position --- src/fingers.go | 66 +++++++++++++++++++++++++++++++++ src/fingers_test.go | 89 +++++++++++++++++++++++++++++++++++++++++++++ src/hands.go | 60 ++++++++++++++++++++++++++++++ src/tt.go | 8 ++++ src/typer.go | 61 +++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 src/fingers.go create mode 100644 src/fingers_test.go create mode 100644 src/hands.go diff --git a/src/fingers.go b/src/fingers.go new file mode 100644 index 0000000..74b4ffc --- /dev/null +++ b/src/fingers.go @@ -0,0 +1,66 @@ +package main + +import "strings" + +var fingerMap = map[rune]string{ + // Left Pinky + '1': "Left Pinky", 'Q': "Left Pinky", 'A': "Left Pinky", 'Z': "Left Pinky", + '!': "Left Pinky", 'q': "Left Pinky", 'a': "Left Pinky", 'z': "Left Pinky", + '`': "Left Pinky", '~': "Left Pinky", + + // Left Ring + '2': "Left Ring", 'W': "Left Ring", 'S': "Left Ring", 'X': "Left Ring", + '@': "Left Ring", 'w': "Left Ring", 's': "Left Ring", 'x': "Left Ring", + + // Left Middle + '3': "Left Middle", 'E': "Left Middle", 'D': "Left Middle", 'C': "Left Middle", + '#': "Left Middle", 'e': "Left Middle", 'd': "Left Middle", 'c': "Left Middle", + + // Left Index + '4': "Left Index", 'R': "Left Index", 'F': "Left Index", 'V': "Left Index", + '$': "Left Index", 'r': "Left Index", 'f': "Left Index", 'v': "Left Index", + '5': "Left Index", 'T': "Left Index", 'G': "Left Index", 'B': "Left Index", + '%': "Left Index", 't': "Left Index", 'g': "Left Index", 'b': "Left Index", + + // Thumbs + ' ': "Thumb", + + // Right Index + '6': "Right Index", 'Y': "Right Index", 'H': "Right Index", 'N': "Right Index", + '^': "Right Index", 'y': "Right Index", 'h': "Right Index", 'n': "Right Index", + '7': "Right Index", 'U': "Right Index", 'J': "Right Index", 'M': "Right Index", + '&': "Right Index", 'u': "Right Index", 'j': "Right Index", 'm': "Right Index", + + // Right Middle + '8': "Right Middle", 'I': "Right Middle", 'K': "Right Middle", ',': "Right Middle", + '*': "Right Middle", 'i': "Right Middle", 'k': "Right Middle", '<': "Right Middle", + + // Right Ring + '9': "Right Ring", 'O': "Right Ring", 'L': "Right Ring", '.': "Right Ring", + '(': "Right Ring", 'o': "Right Ring", 'l': "Right Ring", '>': "Right Ring", + + // Right Pinky + '0': "Right Pinky", 'P': "Right Pinky", ';': "Right Pinky", '/': "Right Pinky", + ')': "Right Pinky", 'p': "Right Pinky", ':': "Right Pinky", '?': "Right Pinky", + '-': "Right Pinky", '[': "Right Pinky", '\'': "Right Pinky", + '_': "Right Pinky", '{': "Right Pinky", '"': "Right Pinky", + '=': "Right Pinky", ']': "Right Pinky", '\\': "Right Pinky", + '+': "Right Pinky", '}': "Right Pinky", '|': "Right Pinky", +} + +func getFingerForRune(r rune) string { + if finger, ok := fingerMap[r]; ok { + return finger + } + return "" +} + +func getHandForFinger(finger string) string { + if strings.Contains(finger, "Left") { + return "Left" + } + if strings.Contains(finger, "Right") { + return "Right" + } + return "" +} diff --git a/src/fingers_test.go b/src/fingers_test.go new file mode 100644 index 0000000..31eeec8 --- /dev/null +++ b/src/fingers_test.go @@ -0,0 +1,89 @@ +package main + +import "testing" + +func TestGetFingerForRune(t *testing.T) { + tests := []struct { + r rune + expect string + }{ + {'1', "Left Pinky"}, + {'Q', "Left Pinky"}, + {'A', "Left Pinky"}, + {'Z', "Left Pinky"}, + {'~', "Left Pinky"}, + {'2', "Left Ring"}, + {'W', "Left Ring"}, + {'S', "Left Ring"}, + {'X', "Left Ring"}, + {'3', "Left Middle"}, + {'E', "Left Middle"}, + {'D', "Left Middle"}, + {'C', "Left Middle"}, + {'4', "Left Index"}, + {'R', "Left Index"}, + {'F', "Left Index"}, + {'V', "Left Index"}, + {'5', "Left Index"}, + {'T', "Left Index"}, + {'G', "Left Index"}, + {'B', "Left Index"}, + {' ', "Thumb"}, + {'6', "Right Index"}, + {'Y', "Right Index"}, + {'H', "Right Index"}, + {'N', "Right Index"}, + {'7', "Right Index"}, + {'U', "Right Index"}, + {'J', "Right Index"}, + {'M', "Right Index"}, + {'8', "Right Middle"}, + {'I', "Right Middle"}, + {'K', "Right Middle"}, + {',', "Right Middle"}, + {'9', "Right Ring"}, + {'O', "Right Ring"}, + {'L', "Right Ring"}, + {'.', "Right Ring"}, + {'0', "Right Pinky"}, + {'P', "Right Pinky"}, + {';', "Right Pinky"}, + {'/', "Right Pinky"}, + {'-', "Right Pinky"}, + {'=', "Right Pinky"}, + {'[', "Right Pinky"}, + {']', "Right Pinky"}, + {'\\', "Right Pinky"}, + {'\'', "Right Pinky"}, + {'`', "Left Pinky"}, + {'a', "Left Pinky"}, + {'z', "Left Pinky"}, + {'!', "Left Pinky"}, + {'@', "Left Ring"}, + {'#', "Left Middle"}, + {'$', "Left Index"}, + {'%', "Left Index"}, + {'^', "Right Index"}, + {'&', "Right Index"}, + {'*', "Right Middle"}, + {'(', "Right Ring"}, + {')', "Right Pinky"}, + {'_', "Right Pinky"}, + {'+', "Right Pinky"}, + {'{', "Right Pinky"}, + {'}', "Right Pinky"}, + {'|', "Right Pinky"}, + {':', "Right Pinky"}, + {'"', "Right Pinky"}, + {'<', "Right Middle"}, + {'>', "Right Ring"}, + {'?', "Right Pinky"}, + } + + for _, tc := range tests { + got := getFingerForRune(tc.r) + if got != tc.expect { + t.Errorf("getFingerForRune(%q): expected %q, got %q", tc.r, tc.expect, got) + } + } +} diff --git a/src/hands.go b/src/hands.go new file mode 100644 index 0000000..bee3a7f --- /dev/null +++ b/src/hands.go @@ -0,0 +1,60 @@ +package main + +import "github.com/gdamore/tcell" + +var handsArt = []string{ + " .-. .-. .-. .-. .-. .-. .-. .-.", + " | | | | | | | | | | | | | | | |", + " | | | | | | | | | | | | | | | |", + " |___| |___| |___| |___| ___ ___ |___| |___| |___| |___|", + " | | | | | | | |", + " | |__| |_| |__| |", + " | | | | | | | |", + " '---------------------' '---' '---' '---------------------'", +} + +// Finger coordinates (x start, x end, y start, y end) relative to art top-left +type rect struct { + x1, x2, y1, y2 int +} + +var fingerCoords = map[string]rect{ + "Left Pinky": {5, 9, 1, 3}, // x: 5 to 9 + "Left Ring": {11, 15, 1, 3}, + "Left Middle": {17, 21, 1, 3}, + "Left Index": {23, 27, 1, 3}, + "Left Thumb": {30, 34, 4, 6}, + "Right Thumb": {36, 40, 4, 6}, + "Right Index": {43, 47, 1, 3}, + "Right Middle": {49, 53, 1, 3}, + "Right Ring": {55, 59, 1, 3}, + "Right Pinky": {61, 65, 1, 3}, +} + +func drawHands(scr tcell.Screen, x, y int, activeFinger string, style tcell.Style, highlightStyle tcell.Style) { + for r, line := range handsArt { + for c, char := range line { + s := style + + // Check collision with active finger + inRegion := false + + if activeRect, ok := fingerCoords[activeFinger]; ok { + // Simple bounding box check + if c >= activeRect.x1 && c <= activeRect.x2 && r >= activeRect.y1 && r <= activeRect.y2 { + inRegion = true + } + } + + // Apply highlight if we are "inside" the finger region + if inRegion { + // Only highlight non-space characters to keep the shape + if char != ' ' { + s = highlightStyle + } + } + + scr.SetContent(x+c, y+r, rune(char), nil, s) + } + } +} diff --git a/src/tt.go b/src/tt.go index aba3ada..a7eaa55 100644 --- a/src/tt.go +++ b/src/tt.go @@ -181,6 +181,8 @@ File Mode reset progress on a given file. Aesthetics -showwpm Display WPM whilst typing. + -fingers Display finger hints. + -visual Display visual hand hints. -theme THEMEFILE The theme to use. -w The maximum line length in characters. This option is -notheme Attempt to use the default terminal theme. @@ -251,6 +253,8 @@ func main() { var themeName string var showWpm bool + var showFingers bool + var showVisual bool var multiMode bool var versionFlag bool var boldFlag bool @@ -271,6 +275,8 @@ func main() { flag.StringVar("eFile, "quotes", "", "") flag.BoolVar(&showWpm, "showwpm", false, "") + flag.BoolVar(&showFingers, "fingers", false, "") + flag.BoolVar(&showVisual, "visual", false, "") flag.BoolVar(&noSkip, "noskip", false, "") flag.BoolVar(&normalCursor, "blockcursor", false, "") flag.BoolVar(&noBackspace, "nobackspace", false, "") @@ -381,6 +387,8 @@ func main() { typer.DisableBackspace = noBackspace typer.BlockCursor = normalCursor typer.ShowWpm = showWpm + typer.ShowFingers = showFingers + typer.ShowVisual = showVisual if timeout != -1 { timeout *= 1E9 diff --git a/src/typer.go b/src/typer.go index f99a844..88450d5 100644 --- a/src/typer.go +++ b/src/typer.go @@ -36,6 +36,8 @@ type typer struct { OnStart func() SkipWord bool ShowWpm bool + ShowFingers bool + ShowVisual bool DisableBackspace bool BlockCursor bool tty io.Writer @@ -259,6 +261,65 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool, } } + if idx < len(text) { + finger := getFingerForRune(text[idx]) + if finger == "Thumb" { + // Determine which hand was last used + var lastHand string + searchIdx := idx - 1 + + // Search backwards for the last non-space, non-thumb character + for searchIdx >= 0 { + prevChar := text[searchIdx] + if prevChar != ' ' && prevChar != '\n' { + prevFinger := getFingerForRune(prevChar) + if prevFinger != "Thumb" { + lastHand = getHandForFinger(prevFinger) + if lastHand != "" { + break + } + } + } + searchIdx-- + } + + if lastHand == "Left" { + finger = "Right Thumb" + } else if lastHand == "Right" { + finger = "Left Thumb" + } else { + // Default to right thumb if no previous context + finger = "Right Thumb" + } + } + + if finger != "" { + if t.ShowFingers { + fw, _ := calcStringDimensions(finger) + drawString(t.Scr, x+nc/2-fw/2, y+nr+ah+2, finger, -1, t.defaultStyle) + } + + if t.ShowVisual { + hw := 67 + hx := (sw - hw) / 2 + + // Prefer pinning to bottom of screen + hy := sh - len(handsArt) - 1 + + // If pinning to bottom overlaps with text block, try directly below text + textBottom := y + nr + ah + 1 + if hy <= textBottom { + hy = textBottom + 1 + } + + // Ensure we don't draw off-screen + if hy+len(handsArt) <= sh { + drawHands(t.Scr, hx, hy, finger, t.defaultStyle, t.currentWordStyle) + } + } + } + } + //Potentially inefficient, but seems to be good enough t.Scr.Show() From 04abbfa8d034f3d95f87e3874b2f60fdbc666fd4 Mon Sep 17 00:00:00 2001 From: AbdelkaderBah Date: Sun, 8 Mar 2026 03:19:48 +0000 Subject: [PATCH 2/3] Add progressive level system and report screen controls Introduce -level flag with 16 progressive typing levels that introduce keys pair-by-pair from home row to full keyboard. Report screen now shows [q] quit and [n] next level hints, with 'n' advancing to the next level in level mode. Co-Authored-By: Claude Opus 4.6 --- src/levels.go | 254 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/tt.go | 62 ++++++++++-- 2 files changed, 309 insertions(+), 7 deletions(-) create mode 100644 src/levels.go diff --git a/src/levels.go b/src/levels.go new file mode 100644 index 0000000..963098f --- /dev/null +++ b/src/levels.go @@ -0,0 +1,254 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// Level defines a typing lesson with specific keys to practice. +type Level struct { + Name string // Human-readable name + NewKeys []rune // Keys introduced at this level + Drills []string // Fixed drill patterns (used when real words are scarce) +} + +// levels defines the progressive key introduction order. +// Each level adds new keys while all previous keys remain available. +var levels = []Level{ + { + Name: "Home anchors (f j)", + NewKeys: []rune{'f', 'j'}, + Drills: []string{"ff", "jj", "fj", "jf", "fjf", "jfj", "ffj", "jjf", "fjfj", "jfjf"}, + }, + { + Name: "Home row expand (d k)", + NewKeys: []rune{'d', 'k'}, + Drills: []string{"dd", "kk", "dk", "kd", "fdk", "jkd", "dkf", "kjf", "dfk", "kfj", "dfjk", "kjfd"}, + }, + { + Name: "Home row expand (s l)", + NewKeys: []rune{'s', 'l'}, + Drills: []string{"ss", "ll", "sl", "ls", "sdf", "lkj", "sldk", "lskf", "flds", "jskl", "flask", "salads"}, + }, + { + Name: "Full home row (a ;)", + NewKeys: []rune{'a', ';'}, + Drills: []string{"aa", "asd", "ads", "ask", "lad", "lads", "dads", "fad", "fads", "flask", "salad", "fall", "falls", "slacks", "lass"}, + }, + { + Name: "Home row center (g h)", + NewKeys: []rune{'g', 'h'}, + Drills: []string{"gag", "hag", "gash", "hash", "shag", "glad", "half", "flash", "lash", "dash", "ghash", "shall"}, + }, + { + Name: "Top row middle (e i)", + NewKeys: []rune{'e', 'i'}, + Drills: []string{"die", "did", "fie", "hid", "side", "hide", "like", "file", "life", "kids", "idea", "skill", "field", "shield"}, + }, + { + Name: "Top row index (r u)", + NewKeys: []rune{'r', 'u'}, + Drills: []string{"red", "fur", "rug", "ride", "rule", "rush", "dire", "used", "sure", "large", "argued", "figure", "league"}, + }, + { + Name: "Top row ring (w o)", + NewKeys: []rune{'w', 'o'}, + Drills: []string{"who", "low", "how", "row", "wood", "word", "work", "show", "would", "world", "follow", "whole", "forward"}, + }, + { + Name: "Top row pinky (q p)", + NewKeys: []rune{'q', 'p'}, + Drills: []string{"pop", "pig", "peg", "page", "push", "pulled", "pered", "keep", "people", "Europe", "equipped"}, + }, + { + Name: "Top row reach (t y)", + NewKeys: []rune{'t', 'y'}, + Drills: []string{"yet", "the", "try", "your", "they", "type", "year", "their", "right", "still", "study", "thirty", "pretty", "system"}, + }, + { + Name: "Bottom row middle (c ,)", + NewKeys: []rune{'c', ','}, + Drills: []string{"cut", "act", "city", "could", "court", "school", "picture", "process", "society", "practice"}, + }, + { + Name: "Bottom row index (v m)", + NewKeys: []rune{'v', 'm'}, + Drills: []string{"vim", "move", "much", "very", "movie", "music", "might", "every", "improve", "provide", "discover"}, + }, + { + Name: "Bottom row ring (x .)", + NewKeys: []rune{'x', '.'}, + Drills: []string{"fix", "mix", "six", "text", "extra", "exist", "excite", "complex", "example", "explore", "express"}, + }, + { + Name: "Bottom row pinky (z /)", + NewKeys: []rune{'z', '/'}, + Drills: []string{"zip", "zoo", "quiz", "size", "prize", "fuzzy", "fizz", "froze", "puzzle", "organize"}, + }, + { + Name: "Bottom row reach (b n)", + NewKeys: []rune{'b', 'n'}, + Drills: []string{"not", "been", "begin", "born", "bring", "brown", "number", "being", "between", "behind", "benefit"}, + }, + { + Name: "Full keyboard", + NewKeys: []rune{}, + Drills: nil, // Uses real words only + }, +} + +// unlockedKeys returns all keys available up to and including the given level. +func unlockedKeys(level int) map[rune]bool { + keys := map[rune]bool{} + for i := 0; i <= level && i < len(levels); i++ { + for _, k := range levels[i].NewKeys { + keys[k] = true + } + } + return keys +} + +// wordFitsKeys returns true if every character in the word is in the allowed key set. +func wordFitsKeys(word string, keys map[rune]bool) bool { + for _, c := range word { + if !keys[c] { + return false + } + } + return len(word) > 0 +} + +// filterWords returns words from the list that only use the given keys. +func filterWords(words []string, keys map[rune]bool) []string { + var result []string + for _, w := range words { + if wordFitsKeys(w, keys) { + result = append(result, w) + } + } + return result +} + +// generateLevelText creates practice text for a given level. +// It mixes drill patterns with real words that fit the unlocked keys. +func generateLevelText(n int, level int, allWords []string) string { + if level < 0 || level >= len(levels) { + level = len(levels) - 1 + } + + keys := unlockedKeys(level) + realWords := filterWords(allWords, keys) + drills := levels[level].Drills + + // Collect drills from current and previous levels + var allDrills []string + for i := 0; i <= level && i < len(levels); i++ { + allDrills = append(allDrills, levels[i].Drills...) + } + + // Filter drills to only those that fit unlocked keys + var validDrills []string + for _, d := range allDrills { + if wordFitsKeys(d, keys) { + validDrills = append(validDrills, d) + } + } + + // Decide mix ratio: more real words as levels progress + // Early levels: mostly drills. Later levels: mostly real words. + var pool []string + if len(realWords) >= 20 { + // Enough real words: 70% real, 30% drills + for i := 0; i < 7; i++ { + pool = append(pool, realWords...) + } + for i := 0; i < 3; i++ { + pool = append(pool, validDrills...) + } + } else if len(realWords) >= 5 { + // Some real words: 40% real, 60% drills + for i := 0; i < 4; i++ { + pool = append(pool, realWords...) + } + for i := 0; i < 6; i++ { + pool = append(pool, validDrills...) + } + } else { + // Few real words: drills + any available real words + pool = append(pool, validDrills...) + for i := 0; i < 3; i++ { + pool = append(pool, realWords...) + } + } + + // Last level: only real words, no drills + if level == len(levels)-1 && drills == nil { + pool = realWords + if len(pool) == 0 { + pool = allWords + } + } + + if len(pool) == 0 { + pool = validDrills + } + if len(pool) == 0 { + pool = []string{"fff", "jjj", "fjf", "jfj"} + } + + return randomText(n, pool) +} + +// generateLevelTest returns a test function for the given level. +func generateLevelTest(level int, n int, g int) func() []segment { + var b []byte + if b = readResource("words", "1000en"); b == nil { + die("Could not load word list for level mode.") + } + + allWords := strings.Fields(string(b)) + + return func() []segment { + segments := make([]segment, g) + for i := 0; i < g; i++ { + segments[i] = segment{generateLevelText(n, level, allWords), ""} + } + return segments + } +} + +// levelName returns a display string for the level. +func levelName(level int) string { + if level < 0 || level >= len(levels) { + return "Unknown" + } + return levels[level].Name +} + +// levelRange returns the total number of levels. +func levelRange() int { + return len(levels) +} + +// printLevels prints all available levels to stdout. +func printLevels() { + for i, l := range levels { + var keyList []string + for _, k := range l.NewKeys { + keyList = append(keyList, string(k)) + } + + newKeysStr := strings.Join(keyList, " ") + if len(keyList) == 0 { + newKeysStr = "all" + } + + Printf(" Level %-2d %-30s New keys: %s\n", i+1, l.Name, newKeysStr) + } +} + +// Printf is a helper that prints to stdout. +func Printf(format string, args ...interface{}) { + fmt.Fprintf(os.Stdout, format, args...) +} diff --git a/src/tt.go b/src/tt.go index a7eaa55..bc1cc87 100644 --- a/src/tt.go +++ b/src/tt.go @@ -86,7 +86,13 @@ func exit(rc int) { os.Exit(rc) } -func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64, attribution string, mistakes []mistake) { +const ( + ReportNext = iota + ReportQuit + ReportNextLevel +) + +func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64, attribution string, mistakes []mistake, isLevelMode bool) int { mistakeStr := "" if attribution != "" { attribution = "\n\nAttribution: " + attribution @@ -102,7 +108,12 @@ func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64, attribution st } } - report := fmt.Sprintf("WPM: %d\nCPM: %d\nAccuracy: %.2f%%%s%s", wpm, cpm, accuracy, mistakeStr, attribution) + hints := "\n\n[q] quit" + if isLevelMode { + hints += " [n] next level" + } + + report := fmt.Sprintf("WPM: %d\nCPM: %d\nAccuracy: %.2f%%%s%s%s", wpm, cpm, accuracy, mistakeStr, attribution, hints) scr.Clear() drawStringAtCenter(scr, report, tcell.StyleDefault) @@ -110,10 +121,20 @@ func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64, attribution st scr.Show() for { - if key, ok := scr.PollEvent().(*tcell.EventKey); ok && key.Key() == tcell.KeyEscape { - return - } else if ok && key.Key() == tcell.KeyCtrlC { + ev, ok := scr.PollEvent().(*tcell.EventKey) + if !ok { + continue + } + + switch { + case ev.Key() == tcell.KeyCtrlC: exit(1) + case ev.Key() == tcell.KeyEscape || ev.Rune() == 'q': + return ReportQuit + case isLevelMode && ev.Rune() == 'n': + return ReportNextLevel + default: + return ReportNext } } } @@ -164,6 +185,9 @@ func createTyper(scr tcell.Screen, bold bool, themeName string) *typer { var usage = `usage: tt [options] [file] Modes + -level N Starts a progressive typing lesson. Each level + introduces new keys while reinforcing previous ones. + Use '-list levels' to see all available levels. -words WORDFILE Specifies the file from which words are randomly drawn (default: 1000en). -quotes QUOTEFILE Starts quote mode in which quotes are randomly drawn @@ -212,7 +236,7 @@ Scripting Misc -list TYPE Lists internal resources of the given type. - TYPE=[themes|quotes|words] + TYPE=[themes|quotes|words|levels] Version -v Print the current version. @@ -258,6 +282,7 @@ func main() { var multiMode bool var versionFlag bool var boldFlag bool + var levelFlag int var err error var testFn func() []segment @@ -292,12 +317,18 @@ func main() { flag.BoolVar(&rawMode, "raw", false, "") flag.BoolVar(&multiMode, "multi", false, "") flag.StringVar(&themeName, "theme", "default", "") + flag.IntVar(&levelFlag, "level", 0, "") flag.StringVar(&listFlag, "list", "", "") flag.Usage = func() { os.Stdout.Write([]byte(usage)) } flag.Parse() if listFlag != "" { + if listFlag == "levels" { + printLevels() + os.Exit(0) + } + prefix := listFlag + "/" for path, _ := range packedFiles { if strings.Index(path, prefix) == 0 { @@ -333,6 +364,11 @@ func main() { } switch { + case levelFlag > 0: + if levelFlag > levelRange() { + die("Level %d does not exist. Use '-list levels' to see available levels (1-%d).", levelFlag, levelRange()) + } + testFn = generateLevelTest(levelFlag-1, n, g) case wordFile != "": testFn = generateWordTest(wordFile, n, g) case quoteFile != "": @@ -433,7 +469,19 @@ func main() { if len(tests[idx]) == 1 { attribution = tests[idx][0].Attribution } - showReport(scr, cpm, wpm, accuracy, attribution, mistakes) + action := showReport(scr, cpm, wpm, accuracy, attribution, mistakes, levelFlag > 0) + switch action { + case ReportQuit: + exit(0) + case ReportNextLevel: + if levelFlag > 0 && levelFlag < levelRange() { + levelFlag++ + testFn = generateLevelTest(levelFlag-1, n, g) + tests = nil + idx = 0 + continue + } + } } if oneShotMode { exit(0) From 59898f579d6a61c62d498994b5a023c159ee088d Mon Sep 17 00:00:00 2001 From: AbdelkaderBah Date: Sun, 8 Mar 2026 03:21:44 +0000 Subject: [PATCH 3/3] docs: Add levels, finger hints, and report keys to README Co-Authored-By: Claude Opus 4.6 --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 439ca6a..5ba3974 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,31 @@ options. - `C-c` exits the test. - `right` moves to the next test. - `left` moves to the previous test. +- On the report screen: `q` quits, `n` advances to the next level (level mode), any other key starts a new test. + +## Levels + +`tt -level N` starts a progressive typing lesson. Each level introduces new +keys while reinforcing previous ones, starting from the home row anchors (f j) +and building up to the full keyboard across 16 levels. Use `tt -list levels` to +see all available levels. + +After completing a level, the report screen shows `[q]` to quit or `[n]` to +advance to the next level. + +## Finger Hints + +`tt -fingers` displays which finger should be used for the current character. +`tt -visual` shows a visual hand diagram highlighting the active finger. ## Examples - `tt -quotes en` Starts quote mode with the builtin quote list 'en'. + - `tt -level 1` Starts a progressive lesson beginning with the home row anchors (f j). - `tt -n 10 -g 5` produces a test consisting of 50 randomly drawn words in 5 groups of 10 words each. - `tt -t 10` starts a timed test lasting 10 seconds. - `tt -theme gruvbox` Starts tt with the gruvbox theme. + - `tt -fingers -visual` Starts tt with finger position hints and visual hand diagram. `tt` is designed to be easily scriptable and integrate nicely with other *nix tools. With a little shell scripting most features the user can