From d0e48a25aa6f66c2d5f9ff731575b99bd601616b Mon Sep 17 00:00:00 2001 From: Djordje Lukic Date: Tue, 16 Jun 2020 09:42:07 +0200 Subject: [PATCH] Implement a progress writer --- go.mod | 2 +- go.sum | 3 +- progress/spinner.go | 39 ++++++++ progress/writer.go | 214 ++++++++++++++++++++++++++++++++++++++++ progress/writer_test.go | 37 +++++++ 5 files changed, 292 insertions(+), 3 deletions(-) create mode 100644 progress/spinner.go create mode 100644 progress/writer.go create mode 100644 progress/writer_test.go diff --git a/go.mod b/go.mod index c601eb70..6d46b57f 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( github.com/google/uuid v1.1.1 github.com/gorilla/mux v1.7.4 // indirect github.com/hashicorp/go-multierror v1.1.0 - github.com/morikuni/aec v1.0.0 // indirect + github.com/morikuni/aec v1.0.0 github.com/onsi/gomega v1.10.1 github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/image-spec v1.0.1 // indirect diff --git a/go.sum b/go.sum index 1b979ca2..a2893b67 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,6 @@ github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA= github.com/Azure/azure-pipeline-go v0.2.1 h1:OLBdZJ3yvOn2MezlWvbrBMTEUQC72zAftRZOMdj5HYo= github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4= -github.com/Azure/azure-sdk-for-go v43.1.0+incompatible h1:m6EAp2Dmb8/t+ToZ2jtmvdp+JBwsdfSlZuBV31WGLGQ= -github.com/Azure/azure-sdk-for-go v43.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go v43.2.0+incompatible h1:H8jfb+wuVlLqyP1Nr6zqapNxqhgwshD5OETJsBO74iY= github.com/Azure/azure-sdk-for-go v43.2.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-storage-file-go v0.7.0 h1:yWoV0MYwzmoSgWACcVkdPolvAULFPNamcQLpIvS/Et4= @@ -330,6 +328,7 @@ golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/progress/spinner.go b/progress/spinner.go new file mode 100644 index 00000000..8abb9821 --- /dev/null +++ b/progress/spinner.go @@ -0,0 +1,39 @@ +package progress + +import "time" + +type spinner struct { + time time.Time + index int + chars []string + stop bool + done string +} + +func newSpinner() *spinner { + return &spinner{ + index: 0, + time: time.Now(), + chars: []string{ + "⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏", + }, + done: "⠿", + } +} + +func (s *spinner) String() string { + if s.stop { + return s.done + } + + d := time.Since(s.time) + if d.Milliseconds() > 100 { + s.index = (s.index + 1) % len(s.chars) + } + + return s.chars[s.index] +} + +func (s *spinner) Stop() { + s.stop = true +} diff --git a/progress/writer.go b/progress/writer.go new file mode 100644 index 00000000..9803f382 --- /dev/null +++ b/progress/writer.go @@ -0,0 +1,214 @@ +package progress + +import ( + "context" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/buger/goterm" + "github.com/morikuni/aec" +) + +// EventStatus indicates the status of an action +type EventStatus int + +const ( + // Working means that the current task is working + Working EventStatus = iota + // Done means that the current task is done + Done + // Error means that the current task has errored + Error +) + +// Event reprensents a progress event +type Event struct { + ID string + Text string + Status EventStatus + StatusText string + Done bool + + startTime time.Time + endTime time.Time + spinner *spinner +} + +func (e *Event) stop() { + e.endTime = time.Now() + e.spinner.Stop() +} + +// Writer can write multiple progress events +type Writer interface { + Start(context.Context) error + Stop() + Event(Event) +} + +type writer struct { + out io.Writer + events map[string]Event + eventIDs []string + repeated bool + numLines int + done chan bool + mtx *sync.RWMutex +} + +// NewWriter returns a new multi-progress writer +func NewWriter(out io.Writer) Writer { + return &writer{ + out: out, + eventIDs: []string{}, + events: map[string]Event{}, + repeated: false, + done: make(chan bool), + mtx: &sync.RWMutex{}, + } +} + +func (w *writer) Start(ctx context.Context) error { + ticker := time.NewTicker(100 * time.Millisecond) + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-w.done: + w.print() + return nil + case <-ticker.C: + w.print() + } + } +} + +func (w *writer) Stop() { + w.done <- true +} + +func (w *writer) Event(e Event) { + w.mtx.Lock() + defer w.mtx.Unlock() + if !contains(w.eventIDs, e.ID) { + w.eventIDs = append(w.eventIDs, e.ID) + } + if _, ok := w.events[e.ID]; ok { + event := w.events[e.ID] + if event.Status != Done && e.Status == Done { + event.stop() + } + event.Status = e.Status + event.Text = e.Text + event.StatusText = e.StatusText + w.events[e.ID] = event + } else { + e.startTime = time.Now() + e.spinner = newSpinner() + w.events[e.ID] = e + } +} + +func (w *writer) print() { + w.mtx.Lock() + defer w.mtx.Unlock() + terminalWidth := goterm.Width() + b := aec.EmptyBuilder + for i := 0; i <= w.numLines; i++ { + b = b.Up(1) + } + if !w.repeated { + b = b.Down(1) + } + w.repeated = true + fmt.Fprint(w.out, b.Column(0).ANSI) + + // Hide the cursor while we are printing + fmt.Fprint(w.out, aec.Hide) + defer fmt.Fprint(w.out, aec.Show) + + firstLine := fmt.Sprintf("[+] Running %d/%d", numDone(w.events), w.numLines) + if w.numLines != 0 && numDone(w.events) == w.numLines { + firstLine = aec.Apply(firstLine, aec.BlueF) + } + fmt.Fprintln(w.out, firstLine) + + var statusPadding int + for _, v := range w.eventIDs { + l := len(fmt.Sprintf("%s %s", w.events[v].ID, w.events[v].Text)) + if statusPadding < l { + statusPadding = l + } + } + + numLines := 0 + for _, v := range w.eventIDs { + line := lineText(w.events[v], terminalWidth, statusPadding) + // nolint: errcheck + fmt.Fprint(w.out, line) + numLines++ + } + + w.numLines = numLines +} + +func lineText(event Event, terminalWidth, statusPadding int) string { + endTime := time.Now() + if event.Status != Working { + endTime = event.endTime + } + + elapsed := endTime.Sub(event.startTime).Seconds() + + textLen := len(fmt.Sprintf("%s %s", event.ID, event.Text)) + padding := statusPadding - textLen + if padding < 0 { + padding = 0 + } + text := fmt.Sprintf(" %s %s %s%s %s", + event.spinner.String(), + event.ID, + event.Text, + strings.Repeat(" ", padding), + event.StatusText, + ) + timer := fmt.Sprintf("%.1fs\n", elapsed) + o := align(text, timer, terminalWidth) + + color := aec.WhiteF + if event.Status == Done { + color = aec.BlueF + } + if event.Status == Error { + color = aec.RedF + } + + return aec.Apply(o, color) +} + +func numDone(events map[string]Event) int { + i := 0 + for _, e := range events { + if e.Status == Done { + i++ + } + } + return i +} + +func align(l, r string, w int) string { + return fmt.Sprintf("%-[2]*[1]s %[3]s", l, w-len(r)-1, r) +} + +func contains(ar []string, needle string) bool { + for _, v := range ar { + if needle == v { + return true + } + } + return false +} diff --git a/progress/writer_test.go b/progress/writer_test.go new file mode 100644 index 00000000..bb7d31b9 --- /dev/null +++ b/progress/writer_test.go @@ -0,0 +1,37 @@ +package progress + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLineText(t *testing.T) { + now := time.Now() + ev := Event{ + ID: "id", + Text: "Text", + Status: Working, + StatusText: "Status", + endTime: now, + startTime: now, + spinner: &spinner{ + chars: []string{"."}, + }, + } + + lineWidth := len(fmt.Sprintf("%s %s", ev.ID, ev.Text)) + + out := lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[37m . id Text Status 0.0s\n\x1b[0m", out) + + ev.Status = Done + out = lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[34m . id Text Status 0.0s\n\x1b[0m", out) + + ev.Status = Error + out = lineText(ev, 50, lineWidth) + assert.Equal(t, "\x1b[31m . id Text Status 0.0s\n\x1b[0m", out) +}