Aetnaeus 4eae6a11f4 Various improvements.
- Added -showwpm
 - Added -g
 - Added -noreport
 - Updated generated themes.
 - Improved automatic line wrapping.
 - Fixed timer bug.
 - Moved -o to -oneshot
 - The exit code now corresponds to the exit action
 - Updated documentation.
2020-12-28 01:04:29 -05:00

285 lines
6.7 KiB
Go

package main
import (
"bytes"
"flag"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/gdamore/tcell"
"github.com/mattn/go-isatty"
)
var scr tcell.Screen
var csvMode bool
type result struct {
wpm int
cpm int
accuracy float64
timestamp int64
}
var results []result
func readConfig() map[string]string {
cfg := map[string]string{}
home, _ := os.LookupEnv("HOME")
path := filepath.Join(home, ".ttrc")
if b, err := ioutil.ReadFile(path); err == nil {
for _, ln := range bytes.Split(b, []byte("\n")) {
a := strings.SplitN(string(ln), ":", 2)
if len(a) == 2 {
cfg[a[0]] = strings.Trim(a[1], " ")
}
}
}
return cfg
}
func exit(rc int) {
scr.Fini()
if csvMode {
for _, r := range results {
fmt.Printf("%d,%d,%.2f,%d\n", r.wpm, r.cpm, r.accuracy, r.timestamp)
}
}
os.Exit(rc)
}
func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64) {
report := fmt.Sprintf("WPM: %d\nCPM: %d\nAccuracy: %.2f%%", wpm, cpm, accuracy)
scr.Clear()
drawStringAtCenter(scr, report, tcell.StyleDefault)
scr.HideCursor()
scr.Show()
for {
if key, ok := scr.PollEvent().(*tcell.EventKey); ok && key.Key() == tcell.KeyEscape {
return
} else if ok && key.Key() == tcell.KeyCtrlC {
exit(1)
}
}
}
func main() {
var n int
var ngroups int
var contentFn func(sw, sh int) []string
var rawMode bool
var oneShotMode bool
var maxLineLen int
var noSkip bool
var noReport bool
var timeout int
var listFlag string
var err error
var themeName string
var showWpm bool
flag.IntVar(&n, "n", 50, "The number of random words which constitute the test.")
flag.IntVar(&ngroups, "g", 1, "The number of groups into which the generated test is split.")
flag.IntVar(&maxLineLen, "w", 80, "The maximum line length in characters. (ignored if -raw is present).")
flag.IntVar(&timeout, "t", -1, "Terminate the test after the given number of seconds.")
flag.BoolVar(&showWpm, "showwpm", false, "Display WPM whilst typing.")
flag.BoolVar(&noSkip, "noskip", false, "Disable word skipping when space is pressed.")
flag.BoolVar(&oneShotMode, "oneshot", false, "Automatically exit after a single run (useful for scripts).")
flag.BoolVar(&noReport, "noreport", false, "Don't show a report at the end of the test (useful in conjunction with -o).")
flag.BoolVar(&csvMode, "csv", false, "Print the test results to stdout in the form wpm,cpm,accuracy,time.")
flag.BoolVar(&rawMode, "raw", false, "Don't reflow text or show one paragraph at a time.")
flag.StringVar(&themeName, "theme", "", "The theme to use (overrides ~/.ttrc).")
flag.StringVar(&listFlag, "list", "", "-list themes prints a list of available themes.")
flag.Usage = func() {
fmt.Println(`Usage: tt [options]
By default tt creates a test consisting of 50 random words. Arbitrary text
can also be piped directly into the program to create a custom test. Each
paragraph of the input is treated as a segment of the test.
E.G
shuf -n 40 /etc/dictionaries-common/words|tt
Note that linebreaks are determined exclusively by the input if -raw is specified.
Keybindings:
<esc> Restarts the test
<C-c> Terminates tt
<C-backspace> Deletes the previous word
Options:`)
flag.PrintDefaults()
}
flag.Parse()
if listFlag == "themes" {
for t, _ := range themes {
fmt.Println(t)
}
os.Exit(0)
}
if !isatty.IsTerminal(os.Stdin.Fd()) {
b, err := ioutil.ReadAll(os.Stdin)
if err != nil {
panic(err)
}
if rawMode {
contentFn = func(sw, sh int) []string { return []string{string(b)} }
} else {
contentFn = func(sw, sh int) []string {
wsz := maxLineLen
if wsz > sw {
wsz = sw - 8
}
s := strings.Replace(string(b), "\r", "", -1)
s = regexp.MustCompile("\n\n+").ReplaceAllString(s, "\n\n")
content := strings.Split(strings.Trim(s, "\n"), "\n\n")
for i, _ := range content {
content[i] = strings.Replace(wordWrap(strings.Trim(content[i], " "), wsz), "\n", " \n", -1)
}
return content
}
}
} else {
contentFn = func(sw, sh int) []string {
wsz := maxLineLen
if wsz > sw {
wsz = sw - 8
}
if ngroups > n {
ngroups = n
}
r := make([]string, ngroups)
sz := n / ngroups
for i := 0; i < ngroups-1; i++ {
r[i] = randomText(sz, wsz)
}
r[ngroups-1] = randomText(sz+n%ngroups, wsz)
return r
}
}
cfg := readConfig()
var bgcol, fgcol, hicol, hicol2, hicol3, errcol tcell.Color
//If theme is explicitly specified as a flag
if themeName != "" {
if theme, ok := themes[themeName]; !ok {
fmt.Fprintf(os.Stderr, "ERROR: %s is not a valid theme (see -list themes for a list of valid options).\n", themeName)
os.Exit(1)
} else {
bgcol = newTcellColor(theme["bgcol"])
fgcol = newTcellColor(theme["fgcol"])
hicol = newTcellColor(theme["hicol"])
hicol2 = newTcellColor(theme["hicol2"])
hicol3 = newTcellColor(theme["hicol3"])
errcol = newTcellColor(theme["errcol"])
}
} else {
//Use the theme as a base
theme := themes["default"]
if c, ok := cfg["theme"]; ok {
if v, ok := themes[c]; ok {
theme = v
}
}
bgcol = newTcellColor(theme["bgcol"])
fgcol = newTcellColor(theme["fgcol"])
hicol = newTcellColor(theme["hicol"])
hicol2 = newTcellColor(theme["hicol2"])
hicol3 = newTcellColor(theme["hicol3"])
errcol = newTcellColor(theme["errcol"])
//Allow individual colours to be overriden
if c, ok := cfg["bgcol"]; ok {
bgcol = newTcellColor(c)
}
if c, ok := cfg["fgcol"]; ok {
fgcol = newTcellColor(c)
}
if c, ok := cfg["hicol"]; ok {
hicol = newTcellColor(c)
}
if c, ok := cfg["hicol2"]; ok {
hicol2 = newTcellColor(c)
}
if c, ok := cfg["hicol3"]; ok {
hicol3 = newTcellColor(c)
}
if c, ok := cfg["errcol"]; ok {
errcol = newTcellColor(c)
}
}
scr, err = tcell.NewScreen()
if err != nil {
panic(err)
}
if err := scr.Init(); err != nil {
panic(err)
}
typer := NewTyper(scr, fgcol, bgcol, hicol, hicol2, hicol3, errcol)
typer.SkipWord = !noSkip
typer.ShowWpm = showWpm
if timeout != -1 {
timeout *= 1E9
}
for {
sw, sh := scr.Size()
nerrs, ncorrect, t, rc := typer.Start(contentFn(sw, sh), time.Duration(timeout))
switch rc {
case TyperComplete:
cpm := int(float64(ncorrect) / (float64(t) / 60E9))
wpm := cpm / 5
accuracy := float64(ncorrect) / float64(nerrs+ncorrect) * 100
results = append(results, result{wpm, cpm, accuracy, time.Now().Unix()})
if !noReport {
showReport(scr, cpm, wpm, accuracy)
}
if oneShotMode {
exit(0)
}
case TyperSigInt:
exit(1)
case TyperResize:
//Resize events restart the test, this shouldn't be a problem in the vast majority of cases
//and allows us to avoid baking rewrapping logic into the typer.
//TODO: implement state-preserving resize (maybe)
}
}
}