diff --git a/go.mod b/go.mod index 091a93c..0a689f4 100644 --- a/go.mod +++ b/go.mod @@ -1,17 +1,17 @@ -module tt +module github.com/jmonroynieto/tt_withTab -go 1.17 +go 1.21.0 require ( github.com/gdamore/tcell v1.4.0 - github.com/mattn/go-isatty v0.0.14 + github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1 + github.com/mattn/go-isatty v0.0.19 ) require ( github.com/gdamore/encoding v1.0.0 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-runewidth v0.0.13 // indirect - github.com/rivo/uniseg v0.2.0 // indirect - golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c // indirect + github.com/lucasb-eyer/go-colorful v1.0.3 // indirect + github.com/mattn/go-runewidth v0.0.7 // indirect + golang.org/x/sys v0.6.0 // indirect golang.org/x/text v0.3.0 // indirect ) diff --git a/go.sum b/go.sum old mode 100644 new mode 100755 index d2a67e6..be4adeb --- a/go.sum +++ b/go.sum @@ -2,20 +2,16 @@ github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdk github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= github.com/gdamore/tcell v1.4.0 h1:vUnHwJRvcPQa3tzi+0QI4U9JINXYJlOz9yiaiPQ2wMU= github.com/gdamore/tcell v1.4.0/go.mod h1:vxEiSDZdW3L+Uhjii9c3375IlDmR05bzxY404ZVSMo0= +github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1 h1:DSA78HTfGC442ChonW9NdWuH5rsfJjTwsYwfIZhYjFo= +github.com/gerow/go-color v0.0.0-20140219113758-125d37f527f1/go.mod h1:uN90NshmoiEU0ECs3cPdEg3wshS8kG9Zez9RmYPuL5A= github.com/lucasb-eyer/go-colorful v1.0.3 h1:QIbQXiugsb+q10B+MI+7DI1oQLdmnep86tWFlaaUAac= github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= -github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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= -github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= -github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c h1:F1jZWGFhYfh0Ci55sIpILtKKK8p3i2/krTr0H1rg74I= -golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/src/datatest.go b/src/datatest.go old mode 100644 new mode 100755 index 11cd7d5..9b1054b --- a/src/datatest.go +++ b/src/datatest.go @@ -2,7 +2,7 @@ package main func generateTestFromData(data []byte, raw bool, split bool) func() []segment { if raw { - return func() []segment { return []segment{segment{string(data), ""}} } + return func() []segment { return []segment{{string(data), "", ""}} } } else if split { paragraphs := getParagraphs(string(data)) i := 0 @@ -11,7 +11,7 @@ func generateTestFromData(data []byte, raw bool, split bool) func() []segment { if i < len(paragraphs) { p := paragraphs[i] i++ - return []segment{segment{p, ""}} + return []segment{{p, "", ""}} } else { return nil } @@ -21,7 +21,7 @@ func generateTestFromData(data []byte, raw bool, split bool) func() []segment { var segments []segment for _, p := range getParagraphs(string(data)) { - segments = append(segments, segment{p, ""}) + segments = append(segments, segment{p, "", ""}) } return segments diff --git a/src/db.go b/src/db.go old mode 100644 new mode 100755 diff --git a/src/filetest.go b/src/filetest.go old mode 100644 new mode 100755 index 7971714..5b158b8 --- a/src/filetest.go +++ b/src/filetest.go @@ -1,7 +1,7 @@ package main import ( - "io/ioutil" + "os" "path/filepath" ) @@ -25,7 +25,7 @@ func generateTestFromFile(path string, startParagraph int) func() []segment { idx := db[path] - 1 - if b, err := ioutil.ReadFile(path); err != nil { + if b, err := os.ReadFile(path); err != nil { die("Failed to read %s.", path) } else { paragraphs = getParagraphs(string(b)) @@ -40,6 +40,6 @@ func generateTestFromFile(path string, startParagraph int) func() []segment { return nil } - return []segment{segment{paragraphs[idx], ""}} + return []segment{{paragraphs[idx], "", ""}} } } diff --git a/src/quotetest.go b/src/quotetest.go old mode 100644 new mode 100755 diff --git a/src/tt.go b/src/tt.go old mode 100644 new mode 100755 index aba3ada..3b431a1 --- a/src/tt.go +++ b/src/tt.go @@ -5,7 +5,7 @@ import ( "encoding/json" "flag" "fmt" - "io/ioutil" + "io" "os" "path/filepath" "regexp" @@ -95,14 +95,14 @@ func showReport(scr tcell.Screen, cpm, wpm int, accuracy float64, attribution st if len(mistakes) > 0 { mistakeStr = "\nMistakes: " for i, m := range mistakes { - mistakeStr += m.Word + mistakeStr += fmt.Sprintf("%q", m.Word) if i != len(mistakes)-1 { mistakeStr += ", " } } } - report := fmt.Sprintf("WPM: %d\nCPM: %d\nAccuracy: %.2f%%%s%s", wpm, cpm, accuracy, mistakeStr, attribution) + report := fmt.Sprintf("WPM: %d\nCPM: %d\nAccuracy: %.2f%%\n%s%s", wpm, cpm, accuracy, wordWrap(mistakeStr, 20), attribution) scr.Clear() drawStringAtCenter(scr, report, tcell.StyleDefault) @@ -293,7 +293,7 @@ func main() { if listFlag != "" { prefix := listFlag + "/" - for path, _ := range packedFiles { + for path := range packedFiles { if strings.Index(path, prefix) == 0 { _, f := filepath.Split(path) fmt.Println(f) @@ -320,19 +320,33 @@ func main() { wsz = sw - 8 } - s = regexp.MustCompile("\\s+").ReplaceAllString(s, " ") - return strings.Replace( - wordWrap(strings.Trim(s, " "), wsz), - "\n", " \n", -1) + s = regexp.MustCompile(`\s+`).ReplaceAllString(s, ` `) + X := wordWrap(strings.TrimSpace(s), wsz) + return X } + rawflow := func(s string) string { + sw, sh := scr.Size() + wsz := maxLineLen + if wsz > sw { + wsz = sw - 8 + } + s = regexp.MustCompile("[ ]{4}").ReplaceAllString(s, "\t") + return softWrap(s, wsz, sh-6) + } + + // maskRendered := func(old, new, s string) string { + // return regexp.MustCompile(old).ReplaceAllString(s, new) + + // } + 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) + b, err := io.ReadAll(os.Stdin) if err != nil { panic(err) } @@ -383,47 +397,54 @@ func main() { typer.ShowWpm = showWpm if timeout != -1 { - timeout *= 1E9 + timeout *= 1e9 } var tests [][]segment - var idx = 0 + var exercise = 0 for { - if idx >= len(tests) { + if exercise >= len(tests) { tests = append(tests, testFn()) } - if tests[idx] == nil { + if tests[exercise] == nil { exit(0) } if !rawMode { - for i, _ := range tests[idx] { - tests[idx][i].Text = reflow(tests[idx][i].Text) + for i := range tests[exercise] { + tests[exercise][i].Text = reflow(tests[exercise][i].Text) + } + } else { + for i := range tests[exercise] { + masked := rawflow(tests[exercise][i].Text) + tests[exercise][i].SetRenderForm(masked) + spacesAsTabs := strings.ReplaceAll(tests[exercise][i].Text, " ", "\t") + tests[exercise][i].SetContent(spacesAsTabs) } } - nerrs, ncorrect, t, rc, mistakes := typer.Start(tests[idx], time.Duration(timeout)) + nerrs, ncorrect, t, rc, mistakes := typer.Start(tests[exercise], time.Duration(timeout), rawMode) saveMistakes(mistakes) switch rc { case TyperNext: - idx++ + exercise++ case TyperPrevious: - if idx > 0 { - idx-- + if exercise > 0 { + exercise-- } case TyperComplete: - cpm := int(float64(ncorrect) / (float64(t) / 60E9)) + 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(), mistakes}) if !noReport { attribution := "" - if len(tests[idx]) == 1 { - attribution = tests[idx][0].Attribution + if len(tests[exercise]) == 1 { + attribution = tests[exercise][0].Attribution } showReport(scr, cpm, wpm, accuracy, attribution, mistakes) } @@ -431,8 +452,9 @@ func main() { exit(0) } - idx++ + exercise++ case TyperSigInt: + //system call reset the terminal, so we need to reinitialize it exit(1) case TyperResize: diff --git a/src/typer.go b/src/typer.go old mode 100644 new mode 100755 index f99a844..8ac13f6 --- a/src/typer.go +++ b/src/typer.go @@ -6,7 +6,7 @@ import ( "io/ioutil" "os" "runtime" - "strconv" + "sort" "time" "github.com/gdamore/tcell" @@ -24,6 +24,22 @@ const ( type segment struct { Text string `json:"text"` Attribution string `json:"attribution"` + renderForm string +} + +func (s *segment) Render() string { + if s.renderForm == "" { + return s.Text + } + return s.renderForm +} + +func (s *segment) SetRenderForm(form string) { + s.renderForm = form +} + +func (s *segment) SetContent(content string) { + s.Text = content } type mistake struct { @@ -46,6 +62,28 @@ type typer struct { incorrectStyle tcell.Style correctStyle tcell.Style defaultStyle tcell.Style + maskedWsStyle tcell.Style + passedWsStyle tcell.Style +} + +// position key is [position in rendered text], position in textbox is an array field of cell +type textBox map[int]cell + +func (txbx textBox) ShowCursor(cursor int, t *typer, rawmode bool) int { + var skips int + w, z := txbx[cursor].ScreenPos[0], txbx[cursor].ScreenPos[1] + if w <= 0 || z <= 0 { + if !rawmode && txbx[cursor].c != 0 { + t.Scr.HideCursor() + } else if rawmode && txbx[cursor].c != 0 { + skips += txbx.ShowCursor(cursor+1, t, rawmode) + return skips + } + w, z = txbx[cursor-1].ScreenPos[0], txbx[cursor-1].ScreenPos[1] + w++ + } + t.Scr.ShowCursor(w, z) + return skips } func NewTyper(scr tcell.Screen, emboldenTypedText bool, fgcol, bgcol, hicol, hicol2, hicol3, errcol tcell.Color) *typer { @@ -64,7 +102,9 @@ func NewTyper(scr tcell.Screen, emboldenTypedText bool, fgcol, bgcol, hicol, hic if emboldenTypedText { correctStyle = correctStyle.Bold(true) } - + _, xBg, _ := def.Decompose() + xFg := tcell.Color(DimColor(hicol2)) + wsChar := def.Foreground(DimColor(xBg)).Background(bgcol) return &typer{ Scr: scr, SkipWord: true, @@ -76,13 +116,14 @@ func NewTyper(scr tcell.Screen, emboldenTypedText bool, fgcol, bgcol, hicol, hic nextWordStyle: def.Foreground(hicol3), incorrectStyle: def.Foreground(errcol), incorrectSpaceStyle: def.Background(errcol), + maskedWsStyle: def.Foreground(xFg), + passedWsStyle: wsChar, } } -func (t *typer) Start(text []segment, timeout time.Duration) (nerrs, ncorrect int, duration time.Duration, rc int, mistakes []mistake) { +func (t *typer) Start(test []segment, timeout time.Duration, rawMode bool) (nerrs, ncorrect int, duration time.Duration, rc int, mistakes []mistake) { timeLeft := timeout - - for i, s := range text { + for i, segment := range test { startImmediately := true var d time.Duration var e, c int @@ -92,35 +133,34 @@ func (t *typer) Start(text []segment, timeout time.Duration) (nerrs, ncorrect in startImmediately = false } - e, c, rc, d, m = t.start(s.Text, timeLeft, startImmediately, s.Attribution) + e, c, rc, d, m = t.processSegment(segment, timeLeft, startImmediately, rawMode) //errors, correct, return code, duration, mistakes nerrs += e ncorrect += c duration += d mistakes = append(mistakes, m...) - - if timeout != -1 { + if timeLeft != -1 { timeLeft -= d - if timeLeft <= 0 { - return - } } - + if timeLeft <= 0 && timeout > 0 { + break + } if rc != TyperComplete { return } } - return } -func extractMistypedWords(text []rune, typed []rune) (mistakes []mistake) { +func extractMistypedWords(text []rune, typed []rune, rawMode bool) (mistakes []mistake) { + mistakes = make([]mistake, 0, 20) var w []rune var t []rune f := false for i := range text { - if text[i] == ' ' { + if text[i] == 32 || text[i] == 10 || (rawMode && (text[i] == 32 || text[i] == 9 || text[i] == 10)) { + //save and reset if f { mistakes = append(mistakes, mistake{string(w), string(t)}) } @@ -128,10 +168,13 @@ func extractMistypedWords(text []rune, typed []rune) (mistakes []mistake) { w = w[:0] t = t[:0] f = false - continue + if !rawMode { + continue + } } - - if text[i] != typed[i] { + if text[i] != typed[i] && text[i] == 10 && typed[i] == 13 { + typed[i] = '\n' + } else if text[i] != typed[i] { f = true } @@ -155,16 +198,20 @@ func extractMistypedWords(text []rune, typed []rune) (mistakes []mistake) { 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 := []rune(s) +func (t *typer) processSegment(excersice segment, timeLimit time.Duration, startImmediately bool, rawMode bool) (nerrs int, ncorrect int, rc int, duration time.Duration, mistakes []mistake) { + //attribution := excersice.Attribution + //remove newlines from text + var rubric = []rune(excersice.Text) + text := []rune(excersice.Render()) typed := make([]rune, len(text)) - + var startTime time.Time sw, sh := scr.Size() - nc, nr := calcStringDimensions(s) + nc, nr := calcStringDimensions_raw(excersice.Render()) x := (sw - nc) / 2 y := (sh - nr) / 2 + typingExrc := typeSet(text, x, y, t, rawMode) //state + if !t.BlockCursor { t.tty.Write([]byte("\033[5 q")) @@ -174,117 +221,153 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool, } t.Scr.SetStyle(t.defaultStyle) - idx := 0 + + pointer := 0 //Tracks typed and rubric + cursor := 0 //Tracks display text, do not use for evaluations calcStats := func() { nerrs = 0 ncorrect = 0 - mistakes = extractMistypedWords(text[:idx], typed[:idx]) - - for i := 0; i < idx; i++ { - if text[i] != '\n' { - if text[i] != typed[i] { - nerrs++ - } 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 - } + mistakes = extractMistypedWords(rubric[:pointer], typed[:pointer], rawMode) - 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 - } + for iter := 0; iter < pointer; iter++ { + if rubric[iter] != typed[iter] { + nerrs++ } else { - style = t.correctStyle + ncorrect++ } - 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 rubric[iter] != typed[iter] && rubric[iter] == '\n' && typed[iter] == '\r' { + nerrs-- + ncorrect++ + } - if timeLimit != -1 && !startTime.IsZero() { - remaining := timeLimit - time.Now().Sub(startTime) - 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.defaultStyle) + draw := func(txb textBox) { + for _, content := range txb { + if content.c == '\r' { + continue } + t.Scr.SetContent(content.ScreenPos[0], content.ScreenPos[1], content.c, nil, content.style) } + t.Scr.Show() + } - //Potentially inefficient, but seems to be good enough + advance := func(ca int, r rune) { //[c]ursor [a]dvance + typed[pointer] = r + pointer++ + cursor += ca - t.Scr.Show() } deleteWord := func() { - if idx == 0 { + if cursor == 0 { return } + cursor-- + + for cursor > 0 && text[cursor] != ' ' && text[cursor] != '\n' && text[cursor] != '·' && text[cursor] != '↩' && text[cursor] != '\r' { + cursor-- + } + if text[cursor] == '·' || text[cursor] == '\n' { + cursor -= 2 + } + } - idx-- + redrawChanges := func() bool { + var ss, sa int // [s]tate [s]tyle and [s]lot [a]djustment + rltvPos := []int{wordBefor, wordBefor, wordBefor, currentWord, nextWord, scndWord, unstagedWord, unstagedWord} + //working with character windows helps + wbIndex := defineWindow(rubric, pointer) //wordBoundary index + if wbIndex[0] == -1 { + return true + } - for idx > 0 && (text[idx] == ' ' || text[idx] == '\n') { - idx-- + //left truncate the relative position array to the available window + if len(wbIndex) < len(rltvPos) { + if pointer-1 < wbIndex[0] { + sa = 0 + //prepend 0 to wbIndex + wbIndex = append(wbIndex, 0) + copy(wbIndex[1:], wbIndex) + wbIndex[0] = 0 + } else { + for iter := range wbIndex { + if pointer <= wbIndex[iter+1] && pointer >= wbIndex[iter] { + sa = iter + break + } + } + } + rltvPos = rltvPos[3-sa:] } - for idx > 0 && text[idx] != ' ' && text[idx] != '\n' { - idx-- + pos := wbIndex[0] //tracks CURSOR + pnt := wbIndex[0] //tracks POINTER + // traverse each of the word boundaries + // search for the current cell where cc.RubricInd == pointer + for i := wbIndex[0]; i < wbIndex[len(wbIndex)-1]; i++ { + if typingExrc[i].RubricInd == pnt { + pos = i + break + } } + wb: + for i := range wbIndex[:len(wbIndex)-1] { + word: + for pnt >= wbIndex[i] && pnt <= wbIndex[i+1] || pos == len(text)-1 { + ss = rltvPos[i] //relative position of the word to the cursor + if ss == currentWord && pointer > pnt { + ss = wordBefor + } + cc := typingExrc[pos] //current cell + if cc.c == 0 { + pnt++ + pos++ + continue word + } + cc.stylize(ss, typed[cc.RubricInd] == rubric[cc.RubricInd]) + typingExrc[pos] = cc // update state + pos++ + if cc.Format == 0 { + pnt++ + continue word + } + //when masked process accordingly depending on what symbol comes first '›' or '↩' use a switch statements + switch cc.c { + case '›': + for mask := 0; mask < 2; mask++ { + cc := typingExrc[pos] //current cell + cc.stylize(ss, typed[cc.RubricInd] == rubric[cc.RubricInd]) + typingExrc[pos] = cc // update state + pos++ + } + if rubric[pnt] != 9 && rubric[pnt] != 10 { + ss++ + } else { + continue wb + } - if text[idx] == ' ' || text[idx] == '\n' { - typed[idx] = text[idx] - idx++ + case '↩': + pnt++ + pos++ + ss++ + } + + } + if ss < 4 { + ss++ + } } + draw(typingExrc) + return false } + ///************** MAIN EVENT LOOP *****************/// + tickerCloser := make(chan bool) //Inject nil events into the main event loop at regular invervals to force an update @@ -304,16 +387,50 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool, go ticker() defer close(tickerCloser) - if startImmediately { - startTime = time.Now() - } + startTime = time.Now() + rc = TyperComplete + //if starttime is set, then the timer is running + // duration = time.Since(startTime) + //redraw function is called for every non- nil event t.Scr.Clear() + t.Scr.ShowCursor(x, y) + _ = t.Scr.PollEvent() // ignores unnecessary resize event at the start + draw(typingExrc) + + var ( + started bool + wpsTicker *time.Ticker = time.NewTicker(time.Second * 1e9) + charsNow int + charsBefore int + ) +listening: for { - redraw() - ev := t.Scr.PollEvent() + if t.ShowWpm { + select { + case <-wpsTicker.C: + charsNow = pointer + charsP2S := charsNow - charsBefore + //wpm is calculated based on the groups of 5characters typed in 2 seconds + // x chars/2 seconds * 60 sec*word/5 chars*min + wpm := charsP2S * 6 + drawString(t.Scr, x+nc/2-4, y-2, fmt.Sprintf("WPM: %-10d\n", wpm), -1, t.defaultStyle) + charsBefore = charsNow + continue listening + default: + if ev == nil { + continue listening + } + } + } else if ev == nil { + continue listening + } + if !started { + wpsTicker = time.NewTicker(time.Second * 2) + started = true + } switch ev := ev.(type) { case *tcell.EventResize: rc = TyperResize @@ -333,12 +450,12 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool, switch key := ev.Key(); key { case tcell.KeyCtrlC: rc = TyperSigInt - return + case tcell.KeyEscape: rc = TyperEscape - return + case tcell.KeyCtrlL: t.Scr.Sync() @@ -360,56 +477,236 @@ func (t *typer) start(s string, timeLimit time.Duration, startImmediately bool, if ev.Modifiers() == tcell.ModAlt || ev.Modifiers() == tcell.ModCtrl { deleteWord() } else { - if idx == 0 { + if cursor == 0 { break } - idx-- + cursor-- + pointer-- + + for cursor > 0 && text[cursor] == '\n' { + cursor-- + pointer-- + } - for idx > 0 && text[idx] == '\n' { - idx-- + if rawMode && text[cursor] == '·' { + cursor -= 2 } + } } - case tcell.KeyRune: - if idx < len(text) { - if t.SkipWord && ev.Rune() == ' ' { - if idx > 0 && text[idx-1] == ' ' && text[idx] != ' ' { //Do nothing on word boundaries. + case tcell.KeyRune, tcell.KeyCtrlI, tcell.KeyEnter: + //ignore tab and new line when not in raw mode + if !rawMode && (ev.Rune() == 9 || ev.Rune() == 10) { + continue + } + if cursor < len(text) { + if t.SkipWord && ev.Rune() == 32 { + if cursor > 0 && text[cursor-1] == 32 && text[cursor] != 32 { //Do nothing on word boundaries. break - } + } else if text[cursor] == '›' { + advance(3, 32) - for idx < len(text) && text[idx] != ' ' && text[idx] != '\n' { - typed[idx] = 0 - idx++ + } + for cursor < len(text) && text[cursor] != 32 && text[cursor] != '\n' { + advance(1, 0) } - if idx < len(text) { - typed[idx] = text[idx] - idx++ + if cursor < len(text) { + advance(1, text[pointer]) } } else { - typed[idx] = ev.Rune() - idx++ - } + if rawMode { + if rubric[pointer] == 9 { + advance(3, 9) + } else if rubric[pointer] == 10 || rubric[pointer] == 13 { + advance(2, 10) + } else { + advance(1, ev.Rune()) + } + + } else { + advance(1, ev.Rune()) - for idx < len(text) && text[idx] == '\n' { - typed[idx] = text[idx] - idx++ + } } } - - if idx == len(text) { - calcStats() - return - } } - default: //tick - if timeLimit != -1 && !startTime.IsZero() && timeLimit <= time.Now().Sub(startTime) { + + cursor += typingExrc.ShowCursor(cursor, t, rawMode) + + fini := redrawChanges() + if fini { + duration = time.Since(startTime) calcStats() return } + } + } +} + +func typeSet(S []rune, x, y int, t *typer, isRaw bool) textBox { + + whatStyles := func(s rune) (waiting tcell.Style, compared tcell.Style, incorrect tcell.Style) { + switch s { + case '›', '·', '↩', '\n', ' ': + return t.maskedWsStyle, t.passedWsStyle, t.incorrectSpaceStyle + default: + return t.defaultStyle, t.correctStyle, t.incorrectStyle + + } + + } + + whatFunction := func(s rune) int { + const ( + normal int = iota + mask + ) + switch s { + case '›', '·', '↩', '\n': + return mask + default: + return normal + } + } + + var current cell + var r textBox = make(map[int]cell) + var breakchar rune + var cx, cy int = x, y + if isRaw { + breakchar = '\r' + } else { + breakchar = '\n' + } + + rubricPos := 0 + wordNum := 1 + for iter := range S { + //s is prerendered and masking wont be affected + if S[iter] == breakchar || S[iter] == '\n' { + cx = x + cy++ + wordNum++ + rubricPos++ + continue + } else if S[iter] == ' ' { + wordNum++ + } + + current.c = S[iter] + current.WaitingStyle, current.ComparedStyle, current.WrongStyle = whatStyles(S[iter]) + current.CursorStyle = t.currentWordStyle + current.AfterCursorStyle = t.nextWordStyle + current.Format = whatFunction(S[iter]) + current.ScreenPos = [2]int{cx, cy} + current.RubricInd = rubricPos + if wordNum > 4 { + current.stylize(unstagedWord, false) + } else { + current.stylize(wordNum, false) + } + r[iter] = current + cx++ + //skips masks + if current.Format == 1 { + switch S[iter] { + case '›', '↩': + continue + case '·': + if S[iter-1] == '›' { + continue + } + } + + } + rubricPos++ + + } + return r +} + +// This is the simple case. +// this includes↩\na newline and›••tabs. +func defineWindow(rubric []rune, pointer int) (wordBoundaryIndex []int) { + if pointer > len(rubric)-1 { + return []int{-1} + } + counti := -1 //value of counts is the number of words + countj := -1 + i := pointer + j := pointer + //check if previous and current cursor are word boundaries + + for ; j < len(rubric)-1; j++ { + if runeInSlice(rubric[j], []rune{' ', '↩', '\t', '\r', '›', '\n'}) { + countj++ + wordBoundaryIndex = append(wordBoundaryIndex, j) + if countj == 3 { + break + } + } + } + if i < 0 { + i = 0 + } + wordBoundaryIndex = append(wordBoundaryIndex, j) + for ; i > 0; i-- { + if runeInSlice(rubric[i], []rune{' ', '\t', '\r', '·', '\n'}) { + if i > 0 && rubric[i-1] == '›' { + continue + } + counti++ + wordBoundaryIndex = append(wordBoundaryIndex, i) + if counti == 2 { + break + } + } + } + if i < 0 { + wordBoundaryIndex = append(wordBoundaryIndex, i) + } - redraw() + //make map with word boundaries + x := map[int]bool{} + for _, v := range wordBoundaryIndex { + x[v] = true + } + wordBoundaryIndex = wordBoundaryIndex[:0] + for k := range x { + wordBoundaryIndex = append(wordBoundaryIndex, k) + } + sort.Ints(wordBoundaryIndex) + return +} + +func runeInSlice(r rune, slice []rune) bool { + for _, s := range slice { + if r == s { + return true } } + return false +} + +const ( + wordBefor int = iota + currentWord + nextWord + scndWord + unstagedWord +) + +func (cx *cell) stylize(rltv int, correct bool) { + var change tcell.Style + + styler := []tcell.Style{cx.ComparedStyle, cx.CursorStyle, cx.AfterCursorStyle, cx.WaitingStyle, cx.WaitingStyle} + + if rltv == wordBefor && !correct { + change = cx.WrongStyle + } else { + change = styler[rltv] + } + cx.style = change } diff --git a/src/util.go b/src/util.go old mode 100644 new mode 100755 index 66f005b..77972e1 --- a/src/util.go +++ b/src/util.go @@ -11,6 +11,7 @@ import ( "time" "github.com/gdamore/tcell" + hue "github.com/gerow/go-color" ) var CONFIG_DIRS []string @@ -25,17 +26,17 @@ func init() { } type cell struct { - c rune + c rune //rendered character style tcell.Style -} -func dbgPrintf(scr tcell.Screen, format string, args ...interface{}) { - for i := 0; i < 80; i++ { - for j := 0; j < 80; j++ { - scr.SetContent(i, j, ' ', nil, tcell.StyleDefault) - } - } - drawString(scr, 0, 0, fmt.Sprintf(format, args...), -1, tcell.StyleDefault) + ScreenPos [2]int + WaitingStyle tcell.Style + ComparedStyle tcell.Style + WrongStyle tcell.Style + CursorStyle tcell.Style + AfterCursorStyle tcell.Style + Format int // 0 = normal, 1 = mask + RubricInd int } func getParagraphs(s string) []string { @@ -44,38 +45,91 @@ func getParagraphs(s string) []string { return strings.Split(strings.Trim(s, "\n"), "\n\n") } -func wordWrapBytes(s []byte, n int) { - sp := 0 - sz := 0 - - for i := 0; i < len(s); i++ { - sz++ +var rawReplace map[rune][]rune = map[rune][]rune{ + '\t': []rune("›··"), + '\n': []rune("↩\n"), +} - if s[i] == '\n' { - s[i] = ' ' +// wordWrapBytes wraps a byte slice to a given width marking the end of lines with a newline or vertical tab when raw is true +func wordWrapBytes(s []rune, n int, isRaw bool) { + sp := 0 // last space + sz := 0 // current size of line + lsp := 0 // last space in line + lineEnd := rune('\n') + r := make([]rune, len(s)) + copy(r, s) + s = s[:0] + if isRaw { + lineEnd = rune('\r') //group separator + } + for i := 0; i < len(r); i++ { + //save last space + if r[i] == '\n' || r[i] == '\t' || r[i] == ' ' || r[i] == '\r' { + sp = len(s) + lsp = sz } - - if s[i] == ' ' { - sp = i + // add replacement if needed + if isRaw { + if r[i] == 0 { + continue + } + new, ok := rawReplace[r[i]] + if !ok { + s = append(s, r[i]) + sz++ + } else { + s = append(s, new...) + if new[len(new)-1] == '\n' { + sp = len(s) - 1 + lsp = sz + } + sz += len(new) + } + } else { + s = append(s, r[i]) + sz++ } - - if sz > n { - if sp != 0 { - s[sp] = '\n' + //check if we need to wrap + if sz > n && sp != 0 { + if isRaw && s[sz-1] != '\n'{ + s = append(s[:sp+1], append([]rune{lineEnd}, s[sp+1:]...)...) + } else { + s[sp] = lineEnd } - sz = i - sp + sz = sz - lsp } } +} +// this function changes a slice by reslicing it up to the last non \x00 character +func sliceTrimmer(s []rune) []rune { + sz := len(s) + for i := len(s) - 1; i >= 0; i-- { + if s[i] != '\x00' { + sz = i + 1 + break + } + } + return s[:sz] } -func wordWrap(s string, n int) string { - r := []byte(s) - wordWrapBytes(r, n) +func wordWrap(s string, wsz int) string { + var notRaw bool + r := []rune(s) + wordWrapBytes(r, wsz, notRaw) return string(r) } +func softWrap(s string, wsz, hsz int) string { + raw := true + var r = make([]rune, len(s)*4) + copy(r, []rune(s)) + wordWrapBytes(r, wsz, raw) + x := string(sliceTrimmer(r)) + return x +} + func init() { rand.Seed(time.Now().Unix()) } @@ -101,20 +155,6 @@ func randomText(n int, words []string) string { return strings.Replace(r, "\n", " \n", -1) } -func stringToCells(s string) []cell { - a := make([]cell, len(s)) - s = strings.TrimRight(s, "\n ") - - len := 0 - for _, r := range s { - a[len].c = r - a[len].style = tcell.StyleDefault - len++ - } - - return a[:len] -} - func drawString(scr tcell.Screen, x, y int, s string, cursorIdx int, style tcell.Style) { sx := x @@ -147,6 +187,7 @@ func drawStringAtCenter(scr tcell.Screen, s string, style tcell.Style) { drawString(scr, x, y, s, -1, style) } +// gives boundarys of a textbox by finding max number of columns and rows in a string when rendered func calcStringDimensions(s string) (nc, nr int) { if s == "" { return 0, 0 @@ -174,6 +215,33 @@ func calcStringDimensions(s string) (nc, nr int) { return } +func calcStringDimensions_raw(s string) (nc, nr int) { + if s == "" { + return 0, 0 + } + + c := 0 + + for _, x := range s { + if x == '\r' || x == '\n' { + nr++ + if c > nc { + nc = c + } + c = 0 + } else { + c++ + } + } + + nr++ + if c > nc { + nc = c + } + + return +} + func newTcellColor(s string) (tcell.Color, error) { if len(s) != 7 || s[0] != '#' { return 0, fmt.Errorf("%s is not a valid hex color", s) @@ -220,3 +288,13 @@ func readResource(typ, name string) []byte { return readPackedFile(filepath.Join(typ, name)) } + +func DimColor(color tcell.Color) tcell.Color { + + r, g, b := color.RGB() + hsl := hue.RGB{R: float64(r), G: float64(g), B: float64(b)}.ToHSL() + hsl.L *= 0.5 + hsl.S *= 0.8 + new := hsl.ToRGB() + return tcell.NewRGBColor(int32(new.R), int32(new.G), int32(new.B)) +} \ No newline at end of file diff --git a/src/wordtest.go b/src/wordtest.go old mode 100644 new mode 100755 index 9e177f0..abacaa1 --- a/src/wordtest.go +++ b/src/wordtest.go @@ -9,12 +9,12 @@ func generateWordTest(name string, n int, g int) func() []segment { 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) + 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), ""} + segments[i] = segment{randomText(n, words), "", ""} } return segments diff --git a/tt.1.gz b/tt.1.gz deleted file mode 100644 index c969d7c..0000000 Binary files a/tt.1.gz and /dev/null differ