v0.4.0
Partial rewrite. Changes too numerous to list (see the man page). Highlights: - Added -quotes. - Added support for navigating between tests via right/left. - Now store the user's position within a file if one is specified. - Improved documentation.
This commit is contained in:
10
CHANGELOG.md
10
CHANGELOG.md
@ -1,3 +1,13 @@
|
||||
# 0.4.0:
|
||||
Too numerous to list (see the man page)
|
||||
|
||||
Highlights:
|
||||
|
||||
- Added -quotes.
|
||||
- Added support for navigating between tests via right/left.
|
||||
- Now store the user's position within a file if one is specified.
|
||||
- Improved documentation.
|
||||
|
||||
# 0.3.0:
|
||||
- Added support for custom word lists (`-words).
|
||||
- `-theme` now accepts a path.
|
||||
|
4
Makefile
4
Makefile
@ -2,9 +2,11 @@ all:
|
||||
go build -o bin/tt *.go
|
||||
install:
|
||||
install -m755 bin/tt /usr/local/bin
|
||||
install -m755 tt.1.gz /usr/share/man/man1
|
||||
assets:
|
||||
python3 ./scripts/themegen.py
|
||||
./scripts/pack themes/ words/ > packed.go
|
||||
./scripts/pack themes/ words/ quotes/ > packed.go
|
||||
pandoc -s -t man -o - man.md|gzip > tt.1.gz
|
||||
rel:
|
||||
GOOS=darwin GOARCH=amd64 go build -o bin/tt-osx *.go
|
||||
GOOS=windows GOARCH=amd64 go build -o bin/tt.exe *.go
|
||||
|
45
README.md
45
README.md
@ -9,19 +9,17 @@ A terminal based typing test.
|
||||
## Linux
|
||||
|
||||
```
|
||||
sudo curl -L https://github.com/lemnos/tt/releases/download/v0.3.0/tt-linux -o /usr/local/bin/tt && sudo chmod +x /usr/local/bin/tt
|
||||
sudo curl -L https://github.com/lemnos/tt/releases/download/v0.4.0/tt-linux -o /usr/local/bin/tt && sudo chmod +x /usr/local/bin/tt
|
||||
sudo curl /usr/share/man/man1/tt.1.gz -o https://github.com/lemnos/tt/releases/download/v0.4.0/tt.1.gz -L
|
||||
```
|
||||
|
||||
## OSX
|
||||
|
||||
```
|
||||
sudo curl -L https://github.com/lemnos/tt/releases/download/v0.3.0/tt-osx -o /usr/local/bin/tt && sudo chmod +x /usr/local/bin/tt
|
||||
sudo curl -L https://github.com/lemnos/tt/releases/download/v0.4.0/tt-osx -o /usr/local/bin/tt && sudo chmod +x /usr/local/bin/tt
|
||||
sudo curl /usr/share/man/man1/tt.1.gz -o https://github.com/lemnos/tt/releases/download/v0.4.0/tt.1.gz -L
|
||||
```
|
||||
|
||||
## Windows
|
||||
|
||||
There is a [windows binary](https://github.com/lemnos/tt/releases/download/0.0.2/tt.exe) but it is largely untested. Using WSL is strongly encouraged (or alternatively switching to a proper OS ;)).
|
||||
|
||||
## From source
|
||||
|
||||
```
|
||||
@ -38,21 +36,33 @@ Best served on a terminal with truecolor and cursor shape support (e.g kitty, it
|
||||
# Usage
|
||||
|
||||
By default 50 words from the top 1000 words in the English language are used to
|
||||
constitute the test. Custom text can be supplied by piping arbitrary text to
|
||||
the program. Each paragraph in the input is shown as a separate segment of the
|
||||
text.
|
||||
constitute the test. Custom text can be supplied by piping arbitrary text to the
|
||||
program. Each paragraph in the input is shown as a separate segment of the text.
|
||||
See `man tt` or `man.md` for a complete description and a comprehensive set of
|
||||
options.
|
||||
|
||||
## Keys
|
||||
|
||||
- Pressing `escape` at any point restarts the test.
|
||||
- `C-c` exits the test.
|
||||
- `right` moves to the next test.
|
||||
- `left` moves to the previous test.
|
||||
|
||||
## Examples
|
||||
|
||||
- `tt -n 10` produces a test consisting of 10 randomly drawn English words
|
||||
- `tt -quotes en` Starts quote mode with the builtin quote list 'en'.
|
||||
- `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 consisting of 50 words
|
||||
- `tt -theme gruvbox` Starts tt with the gruvbox theme
|
||||
- `tt -t 10` starts a timed test lasting 10 seconds.
|
||||
- `tt -theme gruvbox` Starts tt with the gruvbox theme.
|
||||
|
||||
`tt` is designed to be easily scriptable and integrate nicely with
|
||||
other *nix tools. With a little shell scripting most features the user can
|
||||
conceive of should be possible to implement. Below are some simple examples of
|
||||
what can be achieved.
|
||||
|
||||
- `shuf -n 40 /usr/share/dict/words|tt` Produces a test consisting of 40 random words drawn from your system's dictionary.
|
||||
- `curl http://api.quotable.io/random|jq '[.text=.content|.attribution=.author]'|tt -quotes -` Produces a test consisting of a random quote.
|
||||
- `alias ttd='tt -csv >> ~/wpm.csv'` Creates an alias called ttd which keeps a log of progress in your home directory`.
|
||||
|
||||
The default behaviour is equivalent to `tt -n 50`.
|
||||
|
||||
@ -64,14 +74,3 @@ Custom themes and word lists can be defined in `~/.tt/themes` and `~/.tt/words`
|
||||
and used in conjunction with the `-theme` and `-words` flags. A list of
|
||||
preloaded themes and word lists can be found in `words/` and `themes/` and are
|
||||
accessible by default using the respective flags.
|
||||
|
||||
## Recipes
|
||||
|
||||
`tt` is designed to be easily scriptable and integrate nicely with
|
||||
other *nix tools. With a little shell scripting most features the user can
|
||||
conceive of should be possible to implement. Below are some simple examples of
|
||||
what can be achieved.
|
||||
|
||||
- `shuf -n 40 /usr/share/dict/words|tt` Produces a test consisting of 40 random words drawn from your system's dictionary.
|
||||
- `curl http://api.quotable.io/random|jq -r .content|tt` Produces a test consisting of a random quote.
|
||||
- `alias ttd='tt -csv >> ~/wpm.csv'` Creates an alias called ttd which keeps a log of your progress in your home directory`.
|
||||
|
7
TODO
Normal file
7
TODO
Normal file
@ -0,0 +1,7 @@
|
||||
Keep track of errors.
|
||||
Generate word sets based on errors
|
||||
Consolidate documentation.
|
||||
Add option to export errors.
|
||||
Add -nobackspace
|
||||
-adaptive/-tutor mode which learns from your mistake.
|
||||
Add -blind
|
30
datatest.go
Normal file
30
datatest.go
Normal file
@ -0,0 +1,30 @@
|
||||
package main
|
||||
|
||||
func generateTestFromData(data []byte, raw bool, split bool) func() []segment {
|
||||
if raw {
|
||||
return func() []segment { return []segment{segment{string(data), ""}} }
|
||||
} else if split {
|
||||
paragraphs := getParagraphs(string(data))
|
||||
i := 0
|
||||
|
||||
return func() []segment {
|
||||
if i < len(paragraphs) {
|
||||
p := paragraphs[i]
|
||||
i++
|
||||
return []segment{segment{p, ""}}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return func() []segment {
|
||||
var segments []segment
|
||||
|
||||
for _, p := range getParagraphs(string(data)) {
|
||||
segments = append(segments, segment{p, ""})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
}
|
||||
}
|
42
db.go
Normal file
42
db.go
Normal file
@ -0,0 +1,42 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
var FILE_STATE_DB string
|
||||
var MISTAKE_DB string
|
||||
|
||||
func init() {
|
||||
if home, ok := os.LookupEnv("HOME"); !ok {
|
||||
die("Could not resolve home directory.")
|
||||
} else {
|
||||
FILE_STATE_DB = filepath.Join(home, ".tt/", ".db")
|
||||
MISTAKE_DB = filepath.Join(home, ".tt/", ".errors")
|
||||
}
|
||||
}
|
||||
|
||||
func readValue(path string, o interface{}) error {
|
||||
b, err := ioutil.ReadFile(path)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return json.Unmarshal(b, o)
|
||||
}
|
||||
|
||||
func writeValue(path string, o interface{}) {
|
||||
b, err := json.Marshal(o)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(path, b, 0600)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
44
doc
Normal file
44
doc
Normal file
@ -0,0 +1,44 @@
|
||||
usage: tt [options] [file]
|
||||
|
||||
Modes
|
||||
-words WORDFILE Specifies the file from which words are randomly
|
||||
generated (default: 1000en).
|
||||
-quotes QUOTEFILE Starts quote mode in which quotes are randomly generated
|
||||
from the given file. The file should be JSON encoded and
|
||||
have the following form:
|
||||
|
||||
[{"text": "foo", attribution: "bar"}]
|
||||
|
||||
Word Mode
|
||||
-n GROUPSZ Sets the number of words which constitute a group.
|
||||
-g NGROUPS Sets the number of groups which constitute a test.
|
||||
|
||||
File Mode
|
||||
-start PARAGRAPH The offset of the starting paragraph, set this to 0 to
|
||||
reset progress on a given file.
|
||||
Aesthetics
|
||||
-showwpm Display WPM whilst typing.
|
||||
-theme THEMEFILE The theme to use.
|
||||
-w The maximum line length in characters. This option is
|
||||
ignored if -raw is present.
|
||||
|
||||
Test Parameters
|
||||
-t SECONDS Terminate the test after the given number of seconds.
|
||||
-noskip Disable word skipping when space is pressed.
|
||||
|
||||
Scripting
|
||||
-oneshot Automatically exit after a single run.
|
||||
-noreport Don't show a report at the end of a test.
|
||||
-csv Print the test results to stdout in the form:
|
||||
[wpm],[cpm],[accuracy],[timestamp].
|
||||
-raw Don't reflow STDIN text or show one paragraph at a time.
|
||||
Note that line breaks are determined exclusively by the
|
||||
input.
|
||||
-multi Treat each input paragraph as a self contained test.
|
||||
|
||||
Misc
|
||||
-list TYPE Lists internal resources of the given type.
|
||||
TYPE=[themes|quotes|words]
|
||||
|
||||
Version
|
||||
-v Print the current version.
|
45
filetest.go
Normal file
45
filetest.go
Normal file
@ -0,0 +1,45 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func generateTestFromFile(path string, startParagraph int) func() []segment {
|
||||
var paragraphs []string
|
||||
var db map[string]int
|
||||
var err error
|
||||
|
||||
if path, err = filepath.Abs(path); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := readValue(FILE_STATE_DB, &db); err != nil {
|
||||
db = map[string]int{}
|
||||
}
|
||||
|
||||
if startParagraph != -1 {
|
||||
db[path] = startParagraph
|
||||
writeValue(FILE_STATE_DB, db)
|
||||
}
|
||||
|
||||
idx := db[path] - 1
|
||||
|
||||
if b, err := ioutil.ReadFile(path); err != nil {
|
||||
die("Failed to read %s.", path)
|
||||
} else {
|
||||
paragraphs = getParagraphs(string(b))
|
||||
}
|
||||
|
||||
return func() []segment {
|
||||
idx++
|
||||
db[path] = idx
|
||||
writeValue(FILE_STATE_DB, db)
|
||||
|
||||
if idx >= len(paragraphs) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return []segment{segment{paragraphs[idx], ""}}
|
||||
}
|
||||
}
|
1
go.sum
1
go.sum
@ -8,7 +8,6 @@ github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHX
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-runewidth v0.0.7 h1:Ei8KR0497xHyKJPAv59M1dkC+rOZCMBJ+t3fZ+twI54=
|
||||
github.com/mattn/go-runewidth v0.0.7/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756 h1:9nuHUbU8dRnRRfj9KjWUVrJeoexdbeMjttk6Oh1rD10=
|
||||
golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42 h1:vEOn+mP2zCOVzKckCZy6YsCtDblrpj/w7B9nxGNELpg=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
203
man.md
Normal file
203
man.md
Normal file
@ -0,0 +1,203 @@
|
||||
% tt(1)
|
||||
|
||||
# NAME
|
||||
|
||||
tt - A terminal based typing test
|
||||
|
||||
# SYNOPSIS
|
||||
|
||||
usage: tt \[OPTION\]... \[FILE\]
|
||||
|
||||
# DESCRIPTION
|
||||
|
||||
By default tt creates a test consisting of 50 randomly generated words from
|
||||
the top 1000 words in the English language. If provided with a path, tt will
|
||||
use the given file as input treating each paragraph as a separate segment of
|
||||
the test. The program will automatically keep track of your position in the
|
||||
file so subsequent invocations on the same path will place you at the most
|
||||
recent paragraph (-start 0 can be used to reset your position).
|
||||
|
||||
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 unless '-multi' is
|
||||
supplied in which case each paragraph is treated as a separate test.
|
||||
|
||||
# OPTIONS
|
||||
|
||||
## Modes
|
||||
|
||||
-words *WORDFILE*
|
||||
|
||||
: Specifies the file from which words are randomly generated (default: 1000en).
|
||||
|
||||
-quotes *QUOTEFILE*
|
||||
|
||||
: Starts quote mode in which quotes are randomly generated from the given file. The file should be JSON encoded and have the following form:
|
||||
|
||||
[{"text": "foo", attribution: "bar"}]
|
||||
|
||||
## Word Mode
|
||||
|
||||
-n *GROUPSZ*
|
||||
|
||||
: Sets the number of words which constitute a group.
|
||||
|
||||
-g *NGROUPS*
|
||||
|
||||
: Sets the number of groups which constitute a test.
|
||||
|
||||
## File Mode
|
||||
-start *PARAGRAPH*
|
||||
|
||||
: The offset of the starting paragraph, set this to 0 to reset progress on a given file.
|
||||
|
||||
## Aesthetics
|
||||
|
||||
-showwpm
|
||||
|
||||
: Display WPM whilst typing.
|
||||
|
||||
-theme *THEMEFILE*
|
||||
|
||||
: The theme to use.
|
||||
|
||||
-w
|
||||
|
||||
: The maximum line length in characters. This option is ignored if -raw is present.
|
||||
|
||||
## Test Parameters
|
||||
|
||||
-t *SECONDS*
|
||||
|
||||
: Terminate the test after the given number of seconds.
|
||||
|
||||
-noskip
|
||||
|
||||
: Disable word skipping when space is pressed.
|
||||
|
||||
-nohighlight
|
||||
|
||||
: Disable highlighting.
|
||||
|
||||
-highlight1
|
||||
|
||||
: Only highlight the current word.
|
||||
|
||||
-highlight2
|
||||
|
||||
: Only highlight the next word.
|
||||
|
||||
## Scripting
|
||||
|
||||
-oneshot
|
||||
|
||||
: Automatically exit after a single run.
|
||||
|
||||
-noreport
|
||||
|
||||
: Don't show a report at the end of a test.
|
||||
|
||||
-csv
|
||||
|
||||
: Print CSV formatted results.
|
||||
|
||||
Tests have the form:
|
||||
|
||||
```
|
||||
test,[wpm],[cpm],[accuracy],[timestamp].
|
||||
```
|
||||
|
||||
Mistakes have the form:
|
||||
|
||||
```
|
||||
mistake,[word],[typed]
|
||||
```
|
||||
|
||||
-json
|
||||
|
||||
: Print the test output in JSON.
|
||||
|
||||
-raw
|
||||
|
||||
: Don't reflow STDIN text or show one paragraph at a time. Note that line breaks
|
||||
are determined exclusively by the input.
|
||||
|
||||
-multi
|
||||
|
||||
: Treat each input paragraph as a self contained test.
|
||||
|
||||
## Misc
|
||||
|
||||
**-list** *TYPE*\
|
||||
|
||||
Lists internal resources of the given type. TYPE=[themes|quotes|words].
|
||||
|
||||
**-v**\
|
||||
|
||||
Print the current version.
|
||||
|
||||
# EXAMPLES
|
||||
|
||||
Creates a series of tests each consisting of a random quote drawn from the
|
||||
builtin quote file 'en'.
|
||||
```
|
||||
tt -quotes en
|
||||
```
|
||||
|
||||
Creates a series of tests each consisting of 10 random words drawn from
|
||||
words.txt
|
||||
```
|
||||
tt -words words.txt -n 10
|
||||
```
|
||||
|
||||
Starts a sequence of tests in which each test consists of a paragraph from war
|
||||
and peace starting with paragraph 1.
|
||||
```
|
||||
tt ~/war_and_peace.txt -start 1
|
||||
```
|
||||
|
||||
Produces a test consisting of 40 random words draw from
|
||||
the system dictionary (similar to 'tt -n 40').
|
||||
```
|
||||
shuf -n 40 /usr/share/dict/words|tt
|
||||
```
|
||||
|
||||
Starts a test consisting of two randomly drawn quotes from api.quotable.io and
|
||||
prints the output of each test to STDOUT in csv format.
|
||||
```
|
||||
curl https://api.quotable.io/quotes|\
|
||||
jq '[.results[]|.text=.content|.attribution=.author][:2]'|\
|
||||
tt -quotes - -norreport -csv
|
||||
```
|
||||
|
||||
# PATHS
|
||||
|
||||
Some options like **-words** and **-theme** accept a path. If the given path does
|
||||
not exist, the following directories are searched for a file with the given
|
||||
name before falling back to internal resources:
|
||||
|
||||
~/.tt/words\
|
||||
~/.tt/themes\
|
||||
/etc/tt/words\
|
||||
/etc/tt/themes
|
||||
|
||||
# KEYS
|
||||
|
||||
**esc: ** Restarts the test\
|
||||
**C-c: ** Terminates tt\
|
||||
**C-backspace: ** Deletes the previous word\
|
||||
**right** Move to the next test.\
|
||||
**left** Move to the previous test.
|
||||
|
||||
# AUTHOR
|
||||
|
||||
Aetnaeus (aetnaeus@protonmail.com)
|
||||
|
||||
# SEE ALSO
|
||||
|
||||
## Project Page
|
||||
|
||||
https://github.com/lemnos/tt
|
||||
|
||||
# LICENSE
|
||||
|
||||
MIT
|
24
quotetest.go
Normal file
24
quotetest.go
Normal file
@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"math/rand"
|
||||
)
|
||||
|
||||
func generateQuoteTest(name string) func() []segment {
|
||||
var quotes []segment
|
||||
|
||||
if b := readResource("quotes", name); b == nil {
|
||||
die("%s does not appear to be a valid quote file. See '-list quotes' for a list of builtin quotes.", name)
|
||||
} else {
|
||||
err := json.Unmarshal(b, "es)
|
||||
if err != nil {
|
||||
die("Improperly formatted quote file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return func() []segment {
|
||||
idx := rand.Int() % len(quotes)
|
||||
return quotes[idx : idx+1]
|
||||
}
|
||||
}
|
321
tt.go
321
tt.go
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
@ -17,16 +18,20 @@ import (
|
||||
|
||||
var scr tcell.Screen
|
||||
var csvMode bool
|
||||
var jsonMode bool
|
||||
|
||||
type result struct {
|
||||
wpm int
|
||||
cpm int
|
||||
accuracy float64
|
||||
timestamp int64
|
||||
Wpm int `json:"wpm"`
|
||||
Cpm int `json:"cpm"`
|
||||
Accuracy float64 `json:"accuracy"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Mistakes []mistake `json:"mistakes"`
|
||||
}
|
||||
|
||||
func die(format string, args ...interface{}) {
|
||||
if scr != nil {
|
||||
scr.Fini()
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "ERROR: ")
|
||||
fmt.Fprintf(os.Stderr, format, args...)
|
||||
fmt.Fprintf(os.Stderr, "\n")
|
||||
@ -54,17 +59,50 @@ func parseConfig(b []byte) map[string]string {
|
||||
func exit(rc int) {
|
||||
scr.Fini()
|
||||
|
||||
if jsonMode {
|
||||
//Avoid null in serialized JSON.
|
||||
for i := range results {
|
||||
if results[i].Mistakes == nil {
|
||||
results[i].Mistakes = []mistake{}
|
||||
}
|
||||
}
|
||||
|
||||
b, err := json.Marshal(results)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
os.Stdout.Write(b)
|
||||
}
|
||||
|
||||
if csvMode {
|
||||
for _, r := range results {
|
||||
fmt.Printf("%d,%d,%.2f,%d\n", r.wpm, r.cpm, r.accuracy, r.timestamp)
|
||||
fmt.Printf("test,%d,%d,%.2f,%d\n", r.Wpm, r.Cpm, r.Accuracy, r.Timestamp)
|
||||
for _, m := range r.Mistakes {
|
||||
fmt.Printf("mistake,%s,%s\n", m.Word, m.Typed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64, attribution string, mistakes []mistake) {
|
||||
mistakeStr := ""
|
||||
if attribution != "" {
|
||||
attribution = "\n\nAttribution: " + attribution
|
||||
}
|
||||
|
||||
if len(mistakes) > 0 {
|
||||
mistakeStr = "\nMistakes: "
|
||||
for i, m := range mistakes {
|
||||
mistakeStr += m.Word
|
||||
if i != len(mistakes)-1 {
|
||||
mistakeStr += ", "
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report := fmt.Sprintf("WPM: %d\nCPM: %d\nAccuracy: %.2f%%%s%s", wpm, cpm, accuracy, mistakeStr, attribution)
|
||||
|
||||
scr.Clear()
|
||||
drawStringAtCenter(scr, report, tcell.StyleDefault)
|
||||
@ -114,92 +152,123 @@ func createTyper(scr tcell.Screen, themeName string) *typer {
|
||||
return NewTyper(scr, fgcol, bgcol, hicol, hicol2, hicol3, errcol)
|
||||
}
|
||||
|
||||
var usage = `usage: tt [options] [file]
|
||||
|
||||
Modes
|
||||
-words WORDFILE Specifies the file from which words are randomly
|
||||
generated (default: 1000en).
|
||||
-quotes QUOTEFILE Starts quote mode in which quotes are randomly generated
|
||||
from the given file. The file should be JSON encoded and
|
||||
have the following form:
|
||||
|
||||
[{"text": "foo", attribution: "bar"}]
|
||||
|
||||
Word Mode
|
||||
-n GROUPSZ Sets the number of words which constitute a group.
|
||||
-g NGROUPS Sets the number of groups which constitute a test.
|
||||
|
||||
File Mode
|
||||
-start PARAGRAPH The offset of the starting paragraph, set this to 0 to
|
||||
reset progress on a given file.
|
||||
Aesthetics
|
||||
-showwpm Display WPM whilst typing.
|
||||
-theme THEMEFILE The theme to use.
|
||||
-w The maximum line length in characters. This option is
|
||||
ignored if -raw is present.
|
||||
Test Parameters
|
||||
-t SECONDS Terminate the test after the given number of seconds.
|
||||
-noskip Disable word skipping when space is pressed.
|
||||
-nobackspace Disable the backspace key.
|
||||
-nohighlight Disable current and next word highlighting.
|
||||
-highlight1 Only highlight the current word.
|
||||
-highlight2 Only highlight the next word.
|
||||
|
||||
Scripting
|
||||
-oneshot Automatically exit after a single run.
|
||||
-noreport Don't show a report at the end of a test.
|
||||
-csv Print the test results to stdout in the form:
|
||||
[type],[wpm],[cpm],[accuracy],[timestamp].
|
||||
-json Print the test output in JSON.
|
||||
-raw Don't reflow STDIN text or show one paragraph at a time.
|
||||
Note that line breaks are determined exclusively by the
|
||||
input.
|
||||
-multi Treat each input paragraph as a self contained test.
|
||||
|
||||
Misc
|
||||
-list TYPE Lists internal resources of the given type.
|
||||
TYPE=[themes|quotes|words]
|
||||
|
||||
Version
|
||||
-v Print the current version.
|
||||
`
|
||||
|
||||
func saveMistakes(mistakes []mistake) {
|
||||
var db []mistake
|
||||
|
||||
if err := readValue(MISTAKE_DB, &db); err != nil {
|
||||
db = nil
|
||||
}
|
||||
|
||||
db = append(db, mistakes...)
|
||||
writeValue(MISTAKE_DB, db)
|
||||
}
|
||||
|
||||
func main() {
|
||||
var n int
|
||||
var ngroups int
|
||||
var testFn func() []string
|
||||
var g int
|
||||
|
||||
var rawMode bool
|
||||
var oneShotMode bool
|
||||
var noHighlightCurrent bool
|
||||
var noHighlightNext bool
|
||||
var noHighlight bool
|
||||
var maxLineLen int
|
||||
var noSkip bool
|
||||
var noBackspace bool
|
||||
var noReport bool
|
||||
var timeout int
|
||||
var startParagraph int
|
||||
|
||||
var listFlag string
|
||||
var wordList string
|
||||
var err error
|
||||
var wordFile string
|
||||
var quoteFile string
|
||||
|
||||
var themeName string
|
||||
var showWpm bool
|
||||
var multiMode bool
|
||||
var versionFlag bool
|
||||
|
||||
flag.IntVar(&n, "n", 50, "The number of words which constitute a group.")
|
||||
flag.IntVar(&ngroups, "g", 1, "The number of groups which constitute a generated test.")
|
||||
var err error
|
||||
var testFn func() []segment
|
||||
|
||||
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.IntVar(&n, "n", 50, "")
|
||||
flag.IntVar(&g, "g", 1, "")
|
||||
flag.IntVar(&startParagraph, "start", -1, "")
|
||||
|
||||
flag.BoolVar(&versionFlag, "v", false, "Print the current version.")
|
||||
flag.IntVar(&maxLineLen, "w", 80, "")
|
||||
flag.IntVar(&timeout, "t", -1, "")
|
||||
|
||||
flag.StringVar(&wordList, "words", "1000en", "The name of the word list used to generate random text.")
|
||||
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. (note that linebreaks are determined exclusively by the input)")
|
||||
flag.BoolVar(&multiMode, "multi", false, "Treat each input paragraph as a self contained test.")
|
||||
flag.StringVar(&themeName, "theme", "default", "The theme to use.")
|
||||
flag.StringVar(&listFlag, "list", "", "Lists internal resources (e.g -list themes yields a list of builtin themes)")
|
||||
flag.BoolVar(&versionFlag, "v", false, "")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, `Usage: tt [options]
|
||||
flag.StringVar(&wordFile, "words", "", "")
|
||||
flag.StringVar("eFile, "quotes", "", "")
|
||||
|
||||
By default tt creates a test consisting of 50 randomly generated words from the
|
||||
top 1000 words in the English language. 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.
|
||||
flag.BoolVar(&showWpm, "showwpm", false, "")
|
||||
flag.BoolVar(&noSkip, "noskip", false, "")
|
||||
flag.BoolVar(&noBackspace, "nobackspace", false, "")
|
||||
flag.BoolVar(&oneShotMode, "oneshot", false, "")
|
||||
flag.BoolVar(&noHighlight, "nohighlight", false, "")
|
||||
flag.BoolVar(&noHighlightCurrent, "highlight2", false, "")
|
||||
flag.BoolVar(&noHighlightNext, "highlight1", false, "")
|
||||
flag.BoolVar(&noReport, "noreport", false, "")
|
||||
flag.BoolVar(&csvMode, "csv", false, "")
|
||||
flag.BoolVar(&jsonMode, "json", false, "")
|
||||
flag.BoolVar(&rawMode, "raw", false, "")
|
||||
flag.BoolVar(&multiMode, "multi", false, "")
|
||||
flag.StringVar(&themeName, "theme", "default", "")
|
||||
flag.StringVar(&listFlag, "list", "", "")
|
||||
|
||||
Examples:
|
||||
|
||||
# Equivalent to 'tt -n 40 -words /usr/share/dict/words'
|
||||
shuf -n 40 /usr/share/dict/words|tt
|
||||
|
||||
# Starts a test consisting of a random quote.
|
||||
curl https://api.quotable.io/random|jq -r .content|tt
|
||||
|
||||
# Starts single a test consisting of multiple random quotes.
|
||||
curl https://api.quotable.io/quotes|jq -r .results[].content|sort -R|sed -e 's/$/\n/'|tt
|
||||
|
||||
# Starts multiple tests each consisting of a random quote
|
||||
curl https://api.quotable.io/quotes|jq -r .results[].content|sort -R|sed -e 's/$/\n/'|tt -multi
|
||||
|
||||
|
||||
Paths:
|
||||
|
||||
Some options like '-words' and '-theme' accept a path. If the given path does
|
||||
not exist, the following directories are searched for a file with the given
|
||||
name before falling back to the internal resource (if one exists):
|
||||
|
||||
-words (See -list words):
|
||||
|
||||
~/.tt/words/
|
||||
/etc/tt/words/
|
||||
|
||||
-theme (See -list themes):
|
||||
|
||||
~/.tt/themes/
|
||||
/etc/tt/themes/
|
||||
|
||||
Keybindings:
|
||||
<esc> Restarts the test
|
||||
<C-c> Terminates tt
|
||||
<C-backspace> Deletes the previous word
|
||||
|
||||
Options:
|
||||
`)
|
||||
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
flag.Usage = func() { os.Stdout.Write([]byte(usage)) }
|
||||
flag.Parse()
|
||||
|
||||
if listFlag != "" {
|
||||
@ -233,55 +302,23 @@ Options:
|
||||
"\n", " \n", -1)
|
||||
}
|
||||
|
||||
if !isatty.IsTerminal(os.Stdin.Fd()) {
|
||||
switch {
|
||||
case wordFile != "":
|
||||
testFn = generateWordTest(wordFile, n, g)
|
||||
case quoteFile != "":
|
||||
testFn = generateQuoteTest(quoteFile)
|
||||
case !isatty.IsTerminal(os.Stdin.Fd()):
|
||||
b, err := ioutil.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
getParagraphs := func(s string) []string {
|
||||
s = strings.Replace(s, "\r", "", -1)
|
||||
s = regexp.MustCompile("\n\n+").ReplaceAllString(s, "\n\n")
|
||||
return strings.Split(strings.Trim(s, "\n"), "\n\n")
|
||||
}
|
||||
|
||||
if rawMode {
|
||||
testFn = func() []string { return []string{string(b)} }
|
||||
} else if multiMode {
|
||||
paragraphs := getParagraphs(string(b))
|
||||
i := 0
|
||||
|
||||
testFn = func() []string {
|
||||
if i < len(paragraphs) {
|
||||
p := paragraphs[i]
|
||||
i++
|
||||
return []string{p}
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
testFn = func() []string {
|
||||
return getParagraphs(string(b))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
testFn = func() []string {
|
||||
var b []byte
|
||||
|
||||
if b = readResource("words", wordList); b == nil {
|
||||
die("%s does not appear to be a valid word list. See '-list words' for a list of builtin word lists.", wordList)
|
||||
}
|
||||
|
||||
words := regexp.MustCompile("\\s+").Split(string(b), -1)
|
||||
|
||||
r := make([]string, ngroups)
|
||||
for i := 0; i < ngroups; i++ {
|
||||
r[i] = randomText(n, words)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
testFn = generateTestFromData(b, rawMode, multiMode)
|
||||
case len(flag.Args()) > 0:
|
||||
path := flag.Args()[0]
|
||||
testFn = generateTestFromFile(path, startParagraph)
|
||||
default:
|
||||
testFn = generateWordTest("1000en", n, g)
|
||||
}
|
||||
|
||||
scr, err = tcell.NewScreen()
|
||||
@ -301,48 +338,70 @@ Options:
|
||||
}()
|
||||
|
||||
typer := createTyper(scr, themeName)
|
||||
|
||||
if noHighlightNext || noHighlight {
|
||||
typer.currentWordStyle = typer.nextWordStyle
|
||||
typer.nextWordStyle = typer.defaultStyle
|
||||
}
|
||||
|
||||
if noHighlightCurrent || noHighlight {
|
||||
typer.currentWordStyle = typer.defaultStyle
|
||||
}
|
||||
|
||||
typer.SkipWord = !noSkip
|
||||
typer.DisableBackspace = noBackspace
|
||||
typer.ShowWpm = showWpm
|
||||
|
||||
if timeout != -1 {
|
||||
timeout *= 1E9
|
||||
}
|
||||
|
||||
var showNext = true
|
||||
var paragraphs []string
|
||||
var tests [][]segment
|
||||
var idx = 0
|
||||
|
||||
for {
|
||||
if showNext {
|
||||
paragraphs = testFn()
|
||||
|
||||
if paragraphs == nil {
|
||||
exit(0)
|
||||
if idx >= len(tests) {
|
||||
tests = append(tests, testFn())
|
||||
}
|
||||
|
||||
if tests[idx] == nil {
|
||||
exit(0)
|
||||
}
|
||||
|
||||
if !rawMode {
|
||||
for i, _ := range paragraphs {
|
||||
paragraphs[i] = reflow(paragraphs[i])
|
||||
for i, _ := range tests[idx] {
|
||||
tests[idx][i].Text = reflow(tests[idx][i].Text)
|
||||
}
|
||||
}
|
||||
|
||||
nerrs, ncorrect, t, rc := typer.Start(paragraphs, time.Duration(timeout))
|
||||
nerrs, ncorrect, t, rc, mistakes := typer.Start(tests[idx], time.Duration(timeout))
|
||||
saveMistakes(mistakes)
|
||||
|
||||
showNext = false
|
||||
switch rc {
|
||||
case TyperNext:
|
||||
idx++
|
||||
case TyperPrevious:
|
||||
if idx > 0 {
|
||||
idx--
|
||||
}
|
||||
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()})
|
||||
results = append(results, result{wpm, cpm, accuracy, time.Now().Unix(), mistakes})
|
||||
if !noReport {
|
||||
showReport(scr, cpm, wpm, accuracy)
|
||||
attribution := ""
|
||||
if len(tests[idx]) == 1 {
|
||||
attribution = tests[idx][0].Attribution
|
||||
}
|
||||
showReport(scr, cpm, wpm, accuracy, attribution, mistakes)
|
||||
}
|
||||
if oneShotMode {
|
||||
exit(0)
|
||||
}
|
||||
showNext = true
|
||||
|
||||
idx++
|
||||
case TyperSigInt:
|
||||
exit(1)
|
||||
|
||||
|
216
typer.go
216
typer.go
@ -16,14 +16,27 @@ const (
|
||||
TyperComplete = iota
|
||||
TyperSigInt
|
||||
TyperEscape
|
||||
TyperPrevious
|
||||
TyperNext
|
||||
TyperResize
|
||||
)
|
||||
|
||||
type segment struct {
|
||||
Text string `json:"text"`
|
||||
Attribution string `json:"attribution"`
|
||||
}
|
||||
|
||||
type mistake struct {
|
||||
Word string `json:"word"`
|
||||
Typed string `json:"typed"`
|
||||
}
|
||||
|
||||
type typer struct {
|
||||
Scr tcell.Screen
|
||||
OnStart func()
|
||||
SkipWord bool
|
||||
ShowWpm bool
|
||||
DisableBackspace bool
|
||||
tty io.Writer
|
||||
|
||||
currentWordStyle tcell.Style
|
||||
@ -31,7 +44,7 @@ type typer struct {
|
||||
incorrectSpaceStyle tcell.Style
|
||||
incorrectStyle tcell.Style
|
||||
correctStyle tcell.Style
|
||||
backgroundStyle tcell.Style
|
||||
defaultStyle tcell.Style
|
||||
}
|
||||
|
||||
func NewTyper(scr tcell.Screen, fgcol, bgcol, hicol, hicol2, hicol3, errcol tcell.Color) *typer {
|
||||
@ -51,7 +64,7 @@ func NewTyper(scr tcell.Screen, fgcol, bgcol, hicol, hicol2, hicol3, errcol tcel
|
||||
SkipWord: true,
|
||||
tty: tty,
|
||||
|
||||
backgroundStyle: def,
|
||||
defaultStyle: def,
|
||||
correctStyle: def.Foreground(hicol),
|
||||
currentWordStyle: def.Foreground(hicol2),
|
||||
nextWordStyle: def.Foreground(hicol3),
|
||||
@ -60,36 +73,25 @@ func NewTyper(scr tcell.Screen, fgcol, bgcol, hicol, hicol2, hicol3, errcol tcel
|
||||
}
|
||||
}
|
||||
|
||||
func (t *typer) highlight(text []cell, idx int, currentWordStyle, nextWordStyle tcell.Style) {
|
||||
for ; idx < len(text) && text[idx].c != ' ' && text[idx].c != '\n'; idx++ {
|
||||
text[idx].style = currentWordStyle
|
||||
}
|
||||
|
||||
for ; idx < len(text) && (text[idx].c == ' ' || text[idx].c == '\n'); idx++ {
|
||||
}
|
||||
|
||||
for ; idx < len(text) && text[idx].c != ' ' && text[idx].c != '\n'; idx++ {
|
||||
text[idx].style = nextWordStyle
|
||||
}
|
||||
}
|
||||
|
||||
func (t *typer) Start(text []string, timeout time.Duration) (nerrs, ncorrect int, duration time.Duration, rc int) {
|
||||
func (t *typer) Start(text []segment, timeout time.Duration) (nerrs, ncorrect int, duration time.Duration, rc int, mistakes []mistake) {
|
||||
timeLeft := timeout
|
||||
|
||||
for i, p := range text {
|
||||
for i, s := range text {
|
||||
startImmediately := true
|
||||
var d time.Duration
|
||||
var e, c int
|
||||
var m []mistake
|
||||
|
||||
if i == 0 {
|
||||
startImmediately = false
|
||||
}
|
||||
|
||||
e, c, rc, d = t.start(p, timeLeft, startImmediately)
|
||||
e, c, rc, d, m = t.start(s.Text, timeLeft, startImmediately, s.Attribution)
|
||||
|
||||
nerrs += e
|
||||
ncorrect += c
|
||||
duration += d
|
||||
mistakes = append(mistakes, m...)
|
||||
|
||||
if timeout != -1 {
|
||||
timeLeft -= d
|
||||
@ -106,90 +108,173 @@ func (t *typer) Start(text []string, timeout time.Duration) (nerrs, ncorrect int
|
||||
return
|
||||
}
|
||||
|
||||
func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool) (nerrs int, ncorrect int, rc int, duration time.Duration) {
|
||||
func extractMistypedWords(text []rune, typed []rune) (mistakes []mistake) {
|
||||
var w []rune
|
||||
var t []rune
|
||||
f := false
|
||||
|
||||
for i := range text {
|
||||
if text[i] == ' ' {
|
||||
if f {
|
||||
mistakes = append(mistakes, mistake{string(w), string(t)})
|
||||
}
|
||||
|
||||
w = w[:0]
|
||||
t = t[:0]
|
||||
f = false
|
||||
continue
|
||||
}
|
||||
|
||||
if text[i] != typed[i] {
|
||||
f = true
|
||||
}
|
||||
|
||||
if text[i] == 0 {
|
||||
w = append(w, '_')
|
||||
} else {
|
||||
w = append(w, text[i])
|
||||
}
|
||||
|
||||
if typed[i] == 0 {
|
||||
t = append(t, '_')
|
||||
} else {
|
||||
t = append(t, typed[i])
|
||||
}
|
||||
}
|
||||
|
||||
if f {
|
||||
mistakes = append(mistakes, mistake{string(w), string(t)})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool, attribution string) (nerrs int, ncorrect int, rc int, duration time.Duration, mistakes []mistake) {
|
||||
var startTime time.Time
|
||||
text := stringToCells(s)
|
||||
text := []rune(s)
|
||||
typed := make([]rune, len(text))
|
||||
|
||||
sw, sh := scr.Size()
|
||||
nc, nr := calcStringDimensions(s)
|
||||
x := (sw - nc) / 2
|
||||
y := (sh - nr) / 2
|
||||
|
||||
for i, _ := range text {
|
||||
text[i].style = t.backgroundStyle
|
||||
}
|
||||
|
||||
t.tty.Write([]byte("\033[5 q"))
|
||||
|
||||
//Assumes original cursor shape was a block (the one true cursor shape), there doesn't appear to be a
|
||||
//good way to save/restore the shape if the user has changed it from the otcs.
|
||||
defer t.tty.Write([]byte("\033[2 q"))
|
||||
|
||||
t.Scr.SetStyle(t.backgroundStyle)
|
||||
t.Scr.SetStyle(t.defaultStyle)
|
||||
idx := 0
|
||||
|
||||
calcStats := func() {
|
||||
nerrs = 0
|
||||
ncorrect = 0
|
||||
|
||||
for _, c := range text {
|
||||
if c.style == t.incorrectStyle || c.style == t.incorrectSpaceStyle {
|
||||
mistakes = extractMistypedWords(text[:idx], typed[:idx])
|
||||
|
||||
for i := 0; i < idx; i++ {
|
||||
if text[i] != '\n' {
|
||||
if text[i] != typed[i] {
|
||||
nerrs++
|
||||
} else if c.style == t.correctStyle {
|
||||
} else {
|
||||
ncorrect++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rc = TyperComplete
|
||||
duration = time.Now().Sub(startTime)
|
||||
}
|
||||
|
||||
redraw := func() {
|
||||
cx := x
|
||||
cy := y
|
||||
inword := -1
|
||||
|
||||
for i := range text {
|
||||
style := t.defaultStyle
|
||||
|
||||
if text[i] == '\n' {
|
||||
cy++
|
||||
cx = x
|
||||
if inword != -1 {
|
||||
inword++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if i == idx {
|
||||
scr.ShowCursor(cx, cy)
|
||||
inword = 0
|
||||
}
|
||||
|
||||
if i >= idx {
|
||||
if text[i] == ' ' {
|
||||
inword++
|
||||
} else if inword == 0 {
|
||||
style = t.currentWordStyle
|
||||
} else if inword == 1 {
|
||||
style = t.nextWordStyle
|
||||
} else {
|
||||
style = t.defaultStyle
|
||||
}
|
||||
} else if text[i] != typed[i] {
|
||||
if text[i] == ' ' {
|
||||
style = t.incorrectSpaceStyle
|
||||
} else {
|
||||
style = t.incorrectStyle
|
||||
}
|
||||
} else {
|
||||
style = t.correctStyle
|
||||
}
|
||||
|
||||
scr.SetContent(cx, cy, text[i], nil, style)
|
||||
cx++
|
||||
}
|
||||
|
||||
aw, ah := calcStringDimensions(attribution)
|
||||
drawString(t.Scr, x+nc-aw, y+nr+1, attribution, -1, t.defaultStyle)
|
||||
|
||||
if timeLimit != -1 && !startTime.IsZero() {
|
||||
remaining := timeLimit - time.Now().Sub(startTime)
|
||||
drawString(t.Scr, x+nc/2, y+nr+1, " ", -1, t.backgroundStyle)
|
||||
drawString(t.Scr, x+nc/2, y+nr+1, strconv.Itoa(int(remaining/1E9)+1), -1, t.backgroundStyle)
|
||||
drawString(t.Scr, x+nc/2, y+nr+ah+1, " ", -1, t.defaultStyle)
|
||||
drawString(t.Scr, x+nc/2, y+nr+ah+1, strconv.Itoa(int(remaining/1E9)+1), -1, t.defaultStyle)
|
||||
}
|
||||
|
||||
if t.ShowWpm && !startTime.IsZero() {
|
||||
calcStats()
|
||||
if duration > 1E7 { //Avoid flashing large numbers on test start.
|
||||
wpm := int((float64(ncorrect) / 5) / (float64(duration) / 60E9))
|
||||
drawString(t.Scr, x+nc/2-4, y-2, fmt.Sprintf("WPM: %-10d\n", wpm), -1, t.backgroundStyle)
|
||||
drawString(t.Scr, x+nc/2-4, y-2, fmt.Sprintf("WPM: %-10d\n", wpm), -1, t.defaultStyle)
|
||||
}
|
||||
}
|
||||
|
||||
//Potentially inefficient, but seems to be good enough
|
||||
|
||||
drawCells(t.Scr, x, y, text, idx)
|
||||
|
||||
t.Scr.Show()
|
||||
}
|
||||
|
||||
deleteWord := func() {
|
||||
t.highlight(text, idx, t.backgroundStyle, t.backgroundStyle)
|
||||
|
||||
if idx == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
idx--
|
||||
|
||||
for idx > 0 && (text[idx].c == ' ' || text[idx].c == '\n') {
|
||||
text[idx].style = t.backgroundStyle
|
||||
for idx > 0 && (text[idx] == ' ' || text[idx] == '\n') {
|
||||
idx--
|
||||
}
|
||||
|
||||
for idx > 0 && text[idx].c != ' ' && text[idx].c != '\n' {
|
||||
text[idx].style = t.backgroundStyle
|
||||
for idx > 0 && text[idx] != ' ' && text[idx] != '\n' {
|
||||
idx--
|
||||
}
|
||||
|
||||
if text[idx].c == ' ' || text[idx].c == '\n' {
|
||||
if text[idx] == ' ' || text[idx] == '\n' {
|
||||
typed[idx] = text[idx]
|
||||
idx++
|
||||
}
|
||||
|
||||
t.highlight(text, idx, t.currentWordStyle, t.nextWordStyle)
|
||||
}
|
||||
|
||||
tickerCloser := make(chan bool)
|
||||
@ -217,7 +302,6 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool)
|
||||
|
||||
t.Scr.Clear()
|
||||
for {
|
||||
t.highlight(text, idx, t.currentWordStyle, t.nextWordStyle)
|
||||
redraw()
|
||||
|
||||
ev := t.Scr.PollEvent()
|
||||
@ -228,7 +312,9 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool)
|
||||
return
|
||||
case *tcell.EventKey:
|
||||
if runtime.GOOS != "windows" && ev.Key() == tcell.KeyBackspace { //Control+backspace on unix terms
|
||||
if !t.DisableBackspace {
|
||||
deleteWord()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@ -247,60 +333,56 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool)
|
||||
return
|
||||
case tcell.KeyCtrlL:
|
||||
t.Scr.Sync()
|
||||
|
||||
case tcell.KeyRight:
|
||||
rc = TyperNext
|
||||
return
|
||||
|
||||
case tcell.KeyLeft:
|
||||
rc = TyperPrevious
|
||||
return
|
||||
|
||||
case tcell.KeyBackspace, tcell.KeyBackspace2:
|
||||
if !t.DisableBackspace {
|
||||
if ev.Modifiers() == tcell.ModAlt || ev.Modifiers() == tcell.ModCtrl {
|
||||
deleteWord()
|
||||
} else {
|
||||
t.highlight(text, idx, t.backgroundStyle, t.backgroundStyle)
|
||||
|
||||
if idx == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
idx--
|
||||
|
||||
for idx > 0 && text[idx].c == '\n' {
|
||||
for idx > 0 && text[idx] == '\n' {
|
||||
idx--
|
||||
}
|
||||
|
||||
text[idx].style = t.backgroundStyle
|
||||
|
||||
t.highlight(text, idx, t.currentWordStyle, t.nextWordStyle)
|
||||
}
|
||||
}
|
||||
case tcell.KeyRune:
|
||||
if idx < len(text) {
|
||||
switch {
|
||||
case ev.Rune() == text[idx].c:
|
||||
text[idx].style = t.correctStyle
|
||||
idx++
|
||||
case ev.Rune() == ' ' && t.SkipWord:
|
||||
if idx > 0 && text[idx-1].c == ' ' && text[idx].c != ' ' { //Do nothing on word boundaries.
|
||||
if t.SkipWord && ev.Rune() == ' ' {
|
||||
if idx > 0 && text[idx-1] == ' ' && text[idx] != ' ' { //Do nothing on word boundaries.
|
||||
break
|
||||
}
|
||||
|
||||
for idx < len(text) && text[idx].c != ' ' && text[idx].c != '\n' {
|
||||
text[idx].style = t.incorrectStyle
|
||||
for idx < len(text) && text[idx] != ' ' && text[idx] != '\n' {
|
||||
typed[idx] = 0
|
||||
idx++
|
||||
}
|
||||
|
||||
if idx < len(text) {
|
||||
text[idx].style = t.incorrectSpaceStyle
|
||||
typed[idx] = text[idx]
|
||||
idx++
|
||||
}
|
||||
default:
|
||||
if text[idx].c == ' ' {
|
||||
text[idx].style = t.incorrectSpaceStyle
|
||||
} else {
|
||||
text[idx].style = t.incorrectStyle
|
||||
}
|
||||
typed[idx] = ev.Rune()
|
||||
idx++
|
||||
}
|
||||
|
||||
for idx < len(text) && text[idx].c == '\n' {
|
||||
for idx < len(text) && text[idx] == '\n' {
|
||||
typed[idx] = text[idx]
|
||||
idx++
|
||||
}
|
||||
|
||||
t.highlight(text, idx, t.currentWordStyle, t.nextWordStyle)
|
||||
}
|
||||
|
||||
if idx == len(text) {
|
||||
|
41
util.go
41
util.go
@ -6,6 +6,7 @@ import (
|
||||
"math/rand"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -37,6 +38,12 @@ func dbgPrintf(scr tcell.Screen, format string, args ...interface{}) {
|
||||
drawString(scr, 0, 0, fmt.Sprintf(format, args...), -1, tcell.StyleDefault)
|
||||
}
|
||||
|
||||
func getParagraphs(s string) []string {
|
||||
s = strings.Replace(s, "\r", "", -1)
|
||||
s = regexp.MustCompile("\n\n+").ReplaceAllString(s, "\n\n")
|
||||
return strings.Split(strings.Trim(s, "\n"), "\n\n")
|
||||
}
|
||||
|
||||
func wordWrapBytes(s []byte, n int) {
|
||||
sp := 0
|
||||
sz := 0
|
||||
@ -122,28 +129,6 @@ func drawString(scr tcell.Screen, x, y int, s string, cursorIdx int, style tcell
|
||||
}
|
||||
}
|
||||
|
||||
func drawCells(scr tcell.Screen, x, y int, s []cell, cursorIdx int) {
|
||||
sx := x
|
||||
|
||||
for i, c := range s {
|
||||
if c.c == '\n' {
|
||||
y++
|
||||
x = sx
|
||||
} else {
|
||||
scr.SetContent(x, y, c.c, nil, c.style)
|
||||
if i == cursorIdx {
|
||||
scr.ShowCursor(x, y)
|
||||
}
|
||||
|
||||
x++
|
||||
}
|
||||
}
|
||||
|
||||
if cursorIdx == len(s) {
|
||||
scr.ShowCursor(x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func drawStringAtCenter(scr tcell.Screen, s string, style tcell.Style) {
|
||||
nc, nr := calcStringDimensions(s)
|
||||
sw, sh := scr.Size()
|
||||
@ -155,6 +140,10 @@ func drawStringAtCenter(scr tcell.Screen, s string, style tcell.Style) {
|
||||
}
|
||||
|
||||
func calcStringDimensions(s string) (nc, nr int) {
|
||||
if s == "" {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
c := 0
|
||||
|
||||
for _, x := range s {
|
||||
@ -202,6 +191,14 @@ func newTcellColor(s string) (tcell.Color, error) {
|
||||
}
|
||||
|
||||
func readResource(typ, name string) []byte {
|
||||
if name == "-" {
|
||||
b, err := ioutil.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
if b, err := ioutil.ReadFile(name); err == nil {
|
||||
return b
|
||||
|
106
words.go
106
words.go
@ -1,106 +0,0 @@
|
||||
package main
|
||||
|
||||
//Top 1000 words in English.
|
||||
|
||||
var defaultWordList = []string{
|
||||
"as", "I", "his", "that", "he", "was", "for", "on", "are", "with",
|
||||
"they", "be", "at", "one", "have", "this", "from", "by", "hot", "word",
|
||||
"but", "what", "some", "is", "it", "you", "or", "had", "the", "of",
|
||||
"to", "and", "a", "in", "we", "can", "out", "other", "were", "which",
|
||||
"do", "their", "time", "if", "will", "how", "said", "an", "each", "tell",
|
||||
"does", "set", "three", "want", "air", "well", "also", "play", "small", "end",
|
||||
"put", "home", "read", "hand", "port", "large", "spell", "add", "even", "land",
|
||||
"here", "must", "big", "high", "such", "follow", "act", "why", "ask", "men",
|
||||
"change", "went", "light", "kind", "off", "need", "house", "picture", "try", "us",
|
||||
"again", "animal", "point", "mother", "world", "near", "build", "self", "earth", "father",
|
||||
"any", "new", "work", "part", "take", "get", "place", "made", "live", "where",
|
||||
"after", "back", "little", "only", "round", "man", "year", "came", "show", "every",
|
||||
"good", "me", "give", "our", "under", "name", "very", "through", "just", "form",
|
||||
"sentence", "great", "think", "say", "help", "low", "line", "differ", "turn", "cause",
|
||||
"much", "mean", "before", "move", "right", "boy", "old", "too", "same", "she",
|
||||
"all", "there", "when", "up", "use", "your", "way", "about", "many", "then",
|
||||
"them", "write", "would", "like", "so", "these", "her", "long", "make", "thing",
|
||||
"see", "him", "two", "has", "look", "more", "day", "could", "go", "come",
|
||||
"did", "number", "sound", "no", "most", "people", "my", "over", "know", "water",
|
||||
"than", "call", "first", "who", "may", "down", "side", "been", "now", "find",
|
||||
"head", "stand", "own", "page", "should", "country", "found", "answer", "school", "grow",
|
||||
"study", "still", "learn", "plant", "cover", "food", "sun", "four", "between", "state",
|
||||
"keep", "eye", "never", "last", "let", "thought", "city", "tree", "cross", "farm",
|
||||
"hard", "start", "might", "story", "saw", "far", "sea", "draw", "left", "late",
|
||||
"run", "don't", "while", "press", "close", "night", "real", "life", "few", "north",
|
||||
"book", "carry", "took", "science", "eat", "room", "friend", "began", "idea", "fish",
|
||||
"mountain", "stop", "once", "base", "hear", "horse", "cut", "sure", "watch", "color",
|
||||
"face", "wood", "main", "open", "seem", "together", "next", "white", "children", "begin",
|
||||
"got", "walk", "example", "ease", "paper", "group", "always", "music", "those", "both",
|
||||
"mark", "often", "letter", "until", "mile", "river", "car", "feet", "care", "second",
|
||||
"enough", "plain", "girl", "usual", "young", "ready", "above", "ever", "red", "list",
|
||||
"though", "feel", "talk", "bird", "soon", "body", "dog", "family", "direct", "pose",
|
||||
"leave", "song", "measure", "door", "product", "black", "short", "numeral", "class", "wind",
|
||||
"question", "happen", "complete", "ship", "area", "half", "rock", "order", "fire", "south",
|
||||
"problem", "piece", "told", "knew", "pass", "since", "top", "whole", "king", "street",
|
||||
"inch", "multiply", "nothing", "course", "stay", "wheel", "full", "force", "blue", "object",
|
||||
"decide", "surface", "deep", "moon", "island", "foot", "system", "busy", "test", "record",
|
||||
"boat", "common", "gold", "possible", "plane", "stead", "dry", "wonder", "laugh", "thousand",
|
||||
"ago", "ran", "check", "game", "shape", "equate", "hot", "miss", "brought", "heat",
|
||||
"snow", "tire", "bring", "yes", "distant", "fill", "east", "paint", "language", "among",
|
||||
"unit", "power", "town", "fine", "certain", "fly", "fall", "lead", "cry", "dark",
|
||||
"machine", "note", "wait", "plan", "figure", "star", "box", "noun", "field", "rest",
|
||||
"correct", "able", "pound", "done", "beauty", "drive", "stood", "contain", "front", "teach",
|
||||
"week", "final", "gave", "green", "oh", "quick", "develop", "ocean", "warm", "free",
|
||||
"minute", "strong", "special", "mind", "behind", "clear", "tail", "produce", "fact", "space",
|
||||
"heard", "best", "hour", "better", "true", "during", "hundred", "five", "remember", "step",
|
||||
"early", "hold", "west", "ground", "interest", "reach", "fast", "verb", "sing", "listen",
|
||||
"six", "table", "travel", "less", "morning", "ten", "simple", "several", "vowel", "toward",
|
||||
"war", "lay", "against", "pattern", "slow", "center", "love", "person", "money", "serve",
|
||||
"appear", "road", "map", "rain", "rule", "govern", "pull", "cold", "notice", "voice",
|
||||
"energy", "hunt", "probable", "bed", "brother", "egg", "ride", "cell", "believe", "perhaps",
|
||||
"pick", "sudden", "count", "square", "reason", "length", "represent", "art", "subject", "region",
|
||||
"size", "vary", "settle", "speak", "weight", "general", "ice", "matter", "circle", "pair",
|
||||
"include", "divide", "syllable", "felt", "grand", "ball", "yet", "wave", "drop", "heart",
|
||||
"am", "present", "heavy", "dance", "engine", "position", "arm", "wide", "sail", "material",
|
||||
"fraction", "forest", "sit", "race", "window", "store", "summer", "train", "sleep", "prove",
|
||||
"lone", "leg", "exercise", "wall", "catch", "mount", "wish", "sky", "board", "joy",
|
||||
"winter", "sat", "written", "wild", "instrument", "kept", "glass", "grass", "cow", "job",
|
||||
"edge", "sign", "visit", "past", "soft", "fun", "bright", "gas", "weather", "month",
|
||||
"million", "bear", "finish", "happy", "hope", "flower", "clothe", "strange", "gone", "trade",
|
||||
"melody", "trip", "office", "receive", "row", "mouth", "exact", "symbol", "die", "least",
|
||||
"trouble", "shout", "except", "wrote", "seed", "tone", "join", "suggest", "clean", "break",
|
||||
"lady", "yard", "rise", "bad", "blow", "oil", "blood", "touch", "grew", "cent",
|
||||
"mix", "team", "wire", "cost", "lost", "brown", "wear", "garden", "equal", "sent",
|
||||
"choose", "fell", "fit", "flow", "fair", "bank", "collect", "save", "control", "decimal",
|
||||
"ear", "else", "quite", "broke", "case", "middle", "kill", "son", "lake", "moment",
|
||||
"scale", "loud", "spring", "observe", "child", "straight", "consonant", "nation", "dictionary", "milk",
|
||||
"speed", "method", "organ", "pay", "age", "section", "dress", "cloud", "surprise", "quiet",
|
||||
"stone", "tiny", "climb", "cool", "design", "poor", "lot", "experiment", "bottom", "key",
|
||||
"iron", "single", "stick", "flat", "twenty", "skin", "smile", "crease", "hole", "jump",
|
||||
"baby", "eight", "village", "meet", "root", "buy", "raise", "solve", "metal", "whether",
|
||||
"push", "seven", "paragraph", "third", "shall", "held", "hair", "describe", "cook", "floor",
|
||||
"either", "result", "burn", "hill", "safe", "cat", "century", "consider", "type", "law",
|
||||
"bit", "coast", "copy", "phrase", "silent", "tall", "sand", "soil", "roll", "temperature",
|
||||
"finger", "industry", "value", "fight", "lie", "beat", "excite", "natural", "view", "sense",
|
||||
"capital", "won't", "chair", "danger", "fruit", "rich", "thick", "soldier", "process", "operate",
|
||||
"practice", "separate", "difficult", "doctor", "please", "protect", "noon", "crop", "modern", "element",
|
||||
"hit", "student", "corner", "party", "supply", "whose", "locate", "ring", "character", "insect",
|
||||
"caught", "period", "indicate", "radio", "spoke", "atom", "human", "history", "effect", "electric",
|
||||
"expect", "bone", "rail", "imagine", "provide", "agree", "thus", "gentle", "woman", "captain",
|
||||
"guess", "necessary", "sharp", "wing", "create", "neighbor", "wash", "bat", "rather", "crowd",
|
||||
"corn", "compare", "poem", "string", "bell", "depend", "meat", "rub", "tube", "famous",
|
||||
"dollar", "stream", "fear", "sight", "thin", "triangle", "planet", "hurry", "chief", "colony",
|
||||
"clock", "mine", "tie", "enter", "major", "fresh", "search", "send", "yellow", "gun",
|
||||
"allow", "print", "dead", "spot", "desert", "suit", "current", "lift", "rose", "arrive",
|
||||
"master", "track", "parent", "shore", "division", "sheet", "substance", "favor", "connect", "post",
|
||||
"spend", "chord", "fat", "glad", "original", "share", "station", "dad", "bread", "charge",
|
||||
"proper", "bar", "offer", "segment", "slave", "duck", "instant", "market", "degree", "populate",
|
||||
"chick", "dear", "enemy", "reply", "drink", "occur", "support", "speech", "nature", "range",
|
||||
"steam", "motion", "path", "liquid", "log", "meant", "quotient", "teeth", "shell", "neck",
|
||||
"oxygen", "sugar", "death", "pretty", "skill", "women", "season", "solution", "magnet", "silver",
|
||||
"thank", "branch", "match", "suffix", "especially", "fig", "afraid", "huge", "sister", "steel",
|
||||
"discuss", "forward", "similar", "guide", "experience", "score", "apple", "bought", "led", "pitch",
|
||||
"coat", "mass", "card", "band", "rope", "slip", "win", "dream", "evening", "condition",
|
||||
"feed", "tool", "total", "basic", "smell", "valley", "nor", "double", "seat", "continue",
|
||||
"block", "chart", "hat", "sell", "success", "company", "subtract", "event", "particular", "deal",
|
||||
"swim", "term", "opposite", "wife", "shoe", "shoulder", "spread", "arrange", "camp", "invent",
|
||||
"cotton", "born", "determine", "quart", "nine", "truck", "noise", "level", "chance", "gather",
|
||||
"shop", "stretch", "throw", "shine", "property", "column", "molecule", "select", "wrong", "gray",
|
||||
"repeat", "require", "broad", "prepare", "salt", "nose", "plural", "anger", "claim", "continent",
|
||||
}
|
22
wordtest.go
Normal file
22
wordtest.go
Normal file
@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import "regexp"
|
||||
|
||||
func generateWordTest(name string, n int, g int) func() []segment {
|
||||
var b []byte
|
||||
|
||||
if b = readResource("words", name); b == nil {
|
||||
die("%s does not appear to be a valid word list. See '-list words' for a list of builtin word lists.", name)
|
||||
}
|
||||
|
||||
words := regexp.MustCompile("\\s+").Split(string(b), -1)
|
||||
|
||||
return func() []segment {
|
||||
segments := make([]segment, g)
|
||||
for i := 0; i < g; i++ {
|
||||
segments[i] = segment{randomText(n, words), ""}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user