[tools] Extract unicode progress bar to library

Change-Id: Ia0710777e7a133b3e335de917364858dd887618d
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/132640
Reviewed-by: Antonio Maiorano <amaiorano@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
Commit-Queue: Ben Clayton <bclayton@google.com>
diff --git a/tools/src/cmd/run-cts/main.go b/tools/src/cmd/run-cts/main.go
index 4762ea8..f31f846 100644
--- a/tools/src/cmd/run-cts/main.go
+++ b/tools/src/cmd/run-cts/main.go
@@ -24,7 +24,6 @@
 	"fmt"
 	"io"
 	"io/ioutil"
-	"math"
 	"net/http"
 	"os"
 	"os/exec"
@@ -42,6 +41,7 @@
 	"dawn.googlesource.com/dawn/tools/src/cov"
 	"dawn.googlesource.com/dawn/tools/src/fileutils"
 	"dawn.googlesource.com/dawn/tools/src/git"
+	"dawn.googlesource.com/dawn/tools/src/progressbar"
 	"github.com/mattn/go-colorable"
 	"github.com/mattn/go-isatty"
 )
@@ -911,7 +911,7 @@
 	// Helper function for printing a progress bar.
 	lastStatusUpdate, animFrame := time.Now(), 0
 	updateProgress := func() {
-		fmt.Fprint(r.stdout, ansiProgressBar(animFrame, numTests, numByExpectedStatus))
+		drawProgressBar(r.stdout, animFrame, numTests, numByExpectedStatus)
 		animFrame++
 		lastStatusUpdate = time.Now()
 	}
@@ -971,7 +971,7 @@
 			covTree.Add(SplitCTSQuery(res.testcase), res.coverage)
 		}
 	}
-	fmt.Fprint(r.stdout, ansiProgressBar(animFrame, numTests, numByExpectedStatus))
+	drawProgressBar(r.stdout, animFrame, numTests, numByExpectedStatus)
 
 	// All done. Print final stats.
 	fmt.Fprintf(r.stdout, "\nCompleted in %v\n", timeTaken)
@@ -1101,6 +1101,14 @@
 	fail:    red,
 }
 
+var pbStatusColor = map[status]progressbar.Color{
+	pass:    progressbar.Green,
+	warn:    progressbar.Yellow,
+	skip:    progressbar.Cyan,
+	timeout: progressbar.Yellow,
+	fail:    progressbar.Red,
+}
+
 // expectedStatus is a test status, along with a boolean to indicate whether the
 // status matches the test expectations
 type expectedStatus struct {
@@ -1274,69 +1282,26 @@
 	return strings.Repeat(" ", padding) + s
 }
 
-// ansiProgressBar returns a string with an ANSI-colored progress bar, providing
-// realtime information about the status of the CTS run.
+// drawProgressBar draws an ANSI-colored progress bar, providing realtime
+// information about the status of the CTS run.
 // Note: We'll want to skip this if !isatty or if we're running on windows.
-func ansiProgressBar(animFrame int, numTests int, numByExpectedStatus map[expectedStatus]int) string {
-	const barWidth = 50
-
-	animSymbols := []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'}
-	blockSymbols := []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉'}
-
-	numBlocksPrinted := 0
-
-	buf := &strings.Builder{}
-	fmt.Fprint(buf, string(animSymbols[animFrame%len(animSymbols)]), " [")
-	animFrame++
-
-	numFinished := 0
-
+func drawProgressBar(out io.Writer, animFrame int, numTests int, numByExpectedStatus map[expectedStatus]int) {
+	bar := progressbar.Status{Total: numTests}
 	for _, status := range statuses {
 		for _, expected := range []bool{true, false} {
-			color := statusColor[status]
-			if expected {
-				color += bold
+			if num := numByExpectedStatus[expectedStatus{status, expected}]; num > 0 {
+				bar.Segments = append(bar.Segments,
+					progressbar.Segment{
+						Count:       num,
+						Color:       pbStatusColor[status],
+						Bold:        expected,
+						Transparent: expected,
+					})
 			}
-
-			num := numByExpectedStatus[expectedStatus{status, expected}]
-			numFinished += num
-			statusFrac := float64(num) / float64(numTests)
-			fNumBlocks := barWidth * statusFrac
-			fmt.Fprint(buf, color)
-			numBlocks := int(math.Ceil(fNumBlocks))
-			if expected {
-				if numBlocks > 1 {
-					fmt.Fprint(buf, strings.Repeat(string("░"), numBlocks))
-				}
-			} else {
-				if numBlocks > 1 {
-					fmt.Fprint(buf, strings.Repeat(string("▉"), numBlocks))
-				}
-				if numBlocks > 0 {
-					frac := fNumBlocks - math.Floor(fNumBlocks)
-					symbol := blockSymbols[int(math.Round(frac*float64(len(blockSymbols)-1)))]
-					fmt.Fprint(buf, string(symbol))
-				}
-			}
-			numBlocksPrinted += numBlocks
 		}
 	}
-
-	if barWidth > numBlocksPrinted {
-		fmt.Fprint(buf, strings.Repeat(string(" "), barWidth-numBlocksPrinted))
-	}
-	fmt.Fprint(buf, ansiReset)
-	fmt.Fprint(buf, "] ", percentage(numFinished, numTests))
-
-	if colors {
-		// move cursor to start of line so the bar is overridden
-		fmt.Fprint(buf, positionLeft)
-	} else {
-		// cannot move cursor, so newline
-		fmt.Fprintln(buf)
-	}
-
-	return buf.String()
+	const width = 50
+	bar.Draw(out, width, colors, animFrame)
 }
 
 // testcaseStatus is a pair of testcase name and result status
diff --git a/tools/src/progressbar/progressbar.go b/tools/src/progressbar/progressbar.go
new file mode 100644
index 0000000..11c8f16
--- /dev/null
+++ b/tools/src/progressbar/progressbar.go
@@ -0,0 +1,222 @@
+// Copyright 2023 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//   https://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package progressbar provides functions for drawing unicode progress bars to
+// the terminal
+package progressbar
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"math"
+	"strings"
+	"time"
+)
+
+// Defaults for the Config
+const (
+	DefaultRefreshRate = time.Millisecond * 100
+	DefaultWidth       = 50
+	DefaultANSIColors  = true
+)
+
+// Config holds configuration options for a ProgressBar
+type Config struct {
+	RefreshRate time.Duration
+	Width       int
+	ANSIColors  bool
+}
+
+// Color is an enumerator of colors
+type Color int
+
+// Color enumerators
+const (
+	White Color = iota
+	Red
+	Green
+	Yellow
+	Blue
+	Magenta
+	Cyan
+)
+
+// Segment describes a single segment of the ProgressBar
+type Segment struct {
+	Count       int
+	Color       Color
+	Transparent bool
+	Bold        bool
+}
+
+// Status holds the updated data of the ProgressBar
+type Status struct {
+	Total    int
+	Segments []Segment
+}
+
+// ProgressBar returns a string with an ANSI-colored progress bar, providing
+// realtime information about the status of the CTS run.
+// Note: We'll want to skip this if !isatty or if we're running on windows.
+type ProgressBar struct {
+	Config
+	out io.Writer
+	c   chan Status
+}
+
+// New returns a new ProgressBar that streams output to out.
+// Call ProgressBar.Stop() once finished.
+func New(out io.Writer, cfg *Config) *ProgressBar {
+	p := &ProgressBar{out: out, c: make(chan Status)}
+	if cfg != nil {
+		p.Config = *cfg
+	} else {
+		p.ANSIColors = DefaultANSIColors
+	}
+	if p.RefreshRate == 0 {
+		p.RefreshRate = DefaultRefreshRate
+	}
+	if p.Width == 0 {
+		p.Width = DefaultWidth
+	}
+	go func() {
+		var status *Status
+		t := time.NewTicker(p.RefreshRate)
+		defer t.Stop()
+		for frame := 0; ; frame++ {
+			select {
+			case s, ok := <-p.c:
+				if !ok {
+					return
+				}
+				status = &s
+			case <-t.C:
+				if status != nil {
+					status.Draw(out, p.Width, p.ANSIColors, frame)
+				}
+			}
+		}
+	}()
+	return p
+}
+
+// Update updates the ProgressBar with the given status
+func (p *ProgressBar) Update(s Status) {
+	p.c <- s
+}
+
+// Stop stops drawing the progress bar.
+// Once called, the ProgressBar must not be used.
+func (p *ProgressBar) Stop() {
+	close(p.c)
+}
+
+// Draw draws the ProgressBar status to out
+func (s Status) Draw(out io.Writer, width int, ansiColors bool, animFrame int) {
+	// ANSI escape sequences
+	const (
+		escape       = "\u001B["
+		positionLeft = escape + "0G"
+		ansiReset    = escape + "0m"
+
+		bold = escape + "1m"
+
+		red     = escape + "31m"
+		green   = escape + "32m"
+		yellow  = escape + "33m"
+		blue    = escape + "34m"
+		magenta = escape + "35m"
+		cyan    = escape + "36m"
+		white   = escape + "37m"
+	)
+
+	animSymbols := []rune{'⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'}
+	blockSymbols := []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉'}
+
+	numBlocksPrinted := 0
+
+	buf := &bytes.Buffer{}
+	fmt.Fprint(buf, "  ", string(animSymbols[animFrame%len(animSymbols)]), " [")
+
+	numFinished := 0
+	for _, seg := range s.Segments {
+		if ansiColors {
+			switch seg.Color {
+			case Red:
+				buf.WriteString(red)
+			case Green:
+				buf.WriteString(green)
+			case Yellow:
+				buf.WriteString(yellow)
+			case Blue:
+				buf.WriteString(blue)
+			case Magenta:
+				buf.WriteString(magenta)
+			case Cyan:
+				buf.WriteString(cyan)
+			default:
+				buf.WriteString(white)
+			}
+			if seg.Bold {
+				buf.WriteString(bold)
+			}
+		}
+
+		numFinished += seg.Count
+		statusFrac := float64(seg.Count) / float64(s.Total)
+		fNumBlocks := float64(width) * statusFrac
+		numBlocks := int(math.Ceil(fNumBlocks))
+		if seg.Transparent {
+			if numBlocks > 0 {
+				fmt.Fprint(buf, strings.Repeat(string("░"), numBlocks))
+			}
+		} else {
+			if numBlocks > 1 {
+				fmt.Fprint(buf, strings.Repeat(string("▉"), numBlocks-1))
+			}
+			if numBlocks > 0 {
+				frac := fNumBlocks - math.Floor(fNumBlocks)
+				symbol := blockSymbols[int(math.Round(frac*float64(len(blockSymbols)-1)))]
+				fmt.Fprint(buf, string(symbol))
+			}
+		}
+		numBlocksPrinted += numBlocks
+	}
+
+	if width > numBlocksPrinted {
+		fmt.Fprint(buf, strings.Repeat(string(" "), width-numBlocksPrinted))
+	}
+	fmt.Fprint(buf, ansiReset)
+	fmt.Fprint(buf, "] ", percentage(numFinished, s.Total))
+
+	if ansiColors {
+		// move cursor to start of line so the bar is overridden next print
+		fmt.Fprint(buf, positionLeft)
+	} else {
+		// cannot move cursor, so newline
+		fmt.Fprintln(buf)
+	}
+
+	out.Write(buf.Bytes())
+}
+
+// percentage returns the percentage of n out of total as a string
+func percentage(n, total int) string {
+	if total == 0 {
+		return "-"
+	}
+	f := float64(n) / float64(total)
+	return fmt.Sprintf("%.1f%c", f*100.0, '%')
+}