Browse Source

Implement a progress writer

Djordje Lukic 5 years ago
parent
commit
d0e48a25aa
5 changed files with 292 additions and 3 deletions
  1. 1 1
      go.mod
  2. 1 2
      go.sum
  3. 39 0
      progress/spinner.go
  4. 214 0
      progress/writer.go
  5. 37 0
      progress/writer_test.go

+ 1 - 1
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

+ 1 - 2
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=

+ 39 - 0
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
+}

+ 214 - 0
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
+}

+ 37 - 0
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)
+}