« Back to Index

Go: CLI progress spinner

View original Gist on GitHub

Tags: #go #tty

Golang CLI progress spinner.go

package text

import (
	"bytes"
	"context"
	"encoding/hex"
	"fmt"
	"io"
	"os"
	"runtime"
	"strings"
	"sync"
	"time"
	"unicode/utf8"

	"github.com/mattn/go-isatty"
)

// Progress is a producer contract, abstracting over the quiet and verbose
// Progress types. Consumers may use a Progress value in their code, and assign
// it based on the presence of a -v, --verbose flag. Callers are expected to
// call Step for each new major step of their procedural code, and Write with
// the verbose or detailed output of those steps. Callers must eventually call
// either Done or Fail, to signal success or failure respectively.
type Progress interface {
	io.Writer
	Tick(rune)
	Step(string)
	Done()
	Fail()
}

// NewProgress returns a Progress based on the given verbosity level or whether
// the current process is running in a terminal environment.
func NewProgress(output io.Writer, verbose bool) Progress {
	var progress Progress
	if verbose {
		progress = NewVerboseProgress(output)
	} else if isatty.IsTerminal(os.Stdout.Fd()) || isatty.IsCygwinTerminal(os.Stdout.Fd()) {
		progress = NewInteractiveProgress(output)
	} else {
		progress = NewQuietProgress(output)
	}
	return progress
}

// Ticker is a small consumer contract for the Spin function,
// capturing part of the Progress interface.
type Ticker interface {
	Tick(r rune)
}

// Spin calls Tick on the target with the relevant frame every interval. It
// returns when context is canceled, so should be called in its own goroutine.
func Spin(ctx context.Context, frames []rune, interval time.Duration, target Ticker) error {
	var (
		cursor = 0
		ticker = time.NewTicker(interval)
	)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			target.Tick(frames[cursor])
			cursor = (cursor + 1) % len(frames)
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

// InteractiveProgress is an implementation of Progress that includes a spinner at the
// beginning of each Step, and where newline-delimited lines written via Write
// overwrite the current step line in the output.
type InteractiveProgress struct {
	mtx    sync.Mutex
	output io.Writer

	stepHeader     string       // title of current step
	writeBuffer    bytes.Buffer // receives Write calls
	lastBufferLine string       // last full line in writeBuffer
	currentOutput  string       // the content of the current line displayed to user

	cancel func()          // tell Spin to stop
	done   <-chan struct{} // wait for Spin to stop
}

// NewInteractiveProgress returns a InteractiveProgress outputting to the writer.
func NewInteractiveProgress(output io.Writer) *InteractiveProgress {
	p := &InteractiveProgress{
		output:     output,
		stepHeader: "Initializing...",
	}

	var (
		ctx, cancel = context.WithCancel(context.Background())
		done        = make(chan struct{})
	)
	go func() {
		Spin(ctx, []rune{'-', '\\', '|', '/'}, 100*time.Millisecond, p)
		close(done)
	}()
	p.cancel = cancel
	p.done = done

	return p
}

func (p *InteractiveProgress) replaceLine(format string, args ...interface{}) {
	// Clear the current line.
	n := utf8.RuneCountInString(p.currentOutput)
	switch runtime.GOOS {
	case "windows":
		fmt.Fprintf(p.output, "%s\r", strings.Repeat(" ", n))
	default:
		del, _ := hex.DecodeString("7f")
		sequence := fmt.Sprintf("\b%s\b\033[K", del)
		fmt.Fprintf(p.output, "%s\r", strings.Repeat(sequence, n))
	}

	// Generate the new line.
	s := fmt.Sprintf(format, args...)
	p.currentOutput = s
	fmt.Fprint(p.output, p.currentOutput)
}

func (p *InteractiveProgress) getStatus() string {
	if p.lastBufferLine != "" {
		return p.lastBufferLine // takes precedence
	}
	return p.stepHeader
}

// Tick implements the Progress interface.
func (p *InteractiveProgress) Tick(r rune) {
	p.mtx.Lock()
	defer p.mtx.Unlock()

	p.replaceLine("%s %s", string(r), p.getStatus())
}

// Write implements the Progress interface, emitting each incoming byte slice
// to the internal buffer to be written to the terminal on the next tick.
func (p *InteractiveProgress) Write(buf []byte) (int, error) {
	p.mtx.Lock()
	defer p.mtx.Unlock()

	p.writeBuffer.Write(buf)
	p.lastBufferLine = LastFullLine(p.writeBuffer.String())

	return len(buf), nil
}

// Step implements the Progress interface.
func (p *InteractiveProgress) Step(msg string) {
	msg = strings.TrimSpace(msg)

	p.mtx.Lock()
	defer p.mtx.Unlock()

	// Previous step complete.
	p.replaceLine("%s %s", Bold("โœ“"), p.stepHeader)
	fmt.Fprintln(p.output)

	// Reset all the stepwise state.
	p.stepHeader = msg
	p.writeBuffer.Reset()
	p.lastBufferLine = ""
	p.currentOutput = ""

	// New step beginning.
	p.replaceLine("%s %s", Bold("ยท"), p.stepHeader)
}

// Done implements the Progress interface.
func (p *InteractiveProgress) Done() {
	// It's important to cancel the Spin goroutine before taking the lock,
	// because otherwise it's possible to generate a deadlock if the output
	// io.Writer is also synchronized.
	p.cancel()
	<-p.done

	p.mtx.Lock()
	defer p.mtx.Unlock()

	p.replaceLine("%s %s", Bold("โœ“"), p.stepHeader)
	fmt.Fprintln(p.output)
}

// Fail implements the Progress interface.
func (p *InteractiveProgress) Fail() {
	p.cancel()
	<-p.done

	p.mtx.Lock()
	defer p.mtx.Unlock()

	p.replaceLine("%s %s", Bold("โœ—"), p.stepHeader)
	fmt.Fprintln(p.output)
}

// LastFullLine returns the last full \n delimited line in s. That is, s must
// contain at least one \n for LastFullLine to return anything.
func LastFullLine(s string) string {
	last := strings.LastIndex(s, "\n")
	if last < 0 {
		return ""
	}

	prev := strings.LastIndex(s[:last], "\n")
	if prev < 0 {
		prev = 0
	}

	return strings.TrimSpace(s[prev:last])
}

//
//
//

// QuietProgress is an implementation of Progress that attempts to be quiet in
// it's output. I.e. it only prints each Step as it progresses and discards any
// intermediary writes between steps. No spinners are used, therefore it's
// useful for non-TTY environiments, such as CI.
type QuietProgress struct {
	output     io.Writer
	nullWriter io.Writer
}

// NewQuietProgress returns a QuietProgress outputting to the writer.
func NewQuietProgress(output io.Writer) *QuietProgress {
	qp := &QuietProgress{
		output:     output,
		nullWriter: io.Discard,
	}
	qp.Step("Initializing...")
	return qp
}

// Tick implements the Progress interface. It's a no-op.
func (p *QuietProgress) Tick(r rune) {}

// Tick implements the Progress interface.
func (p *QuietProgress) Write(buf []byte) (int, error) {
	return p.nullWriter.Write(buf)
}

// Step implements the Progress interface.
func (p *QuietProgress) Step(msg string) {
	fmt.Fprintln(p.output, strings.TrimSpace(msg))
}

// Done implements the Progress interface. It's a no-op.
func (p *QuietProgress) Done() {}

// Fail implements the Progress interface. It's a no-op.
func (p *QuietProgress) Fail() {}

//
//
//

// VerboseProgress is an implementation of Progress that treats Step and Write
// more or less the same: it simply pipes all output to the provided Writer. No
// spinners are used.
type VerboseProgress struct {
	output io.Writer
}

// NewVerboseProgress returns a VerboseProgress outputting to the writer.
func NewVerboseProgress(output io.Writer) *VerboseProgress {
	return &VerboseProgress{
		output: output,
	}
}

// Tick implements the Progress interface. It's a no-op.
func (p *VerboseProgress) Tick(r rune) {}

// Tick implements the Progress interface.
func (p *VerboseProgress) Write(buf []byte) (int, error) {
	return p.output.Write(buf)
}

// Step implements the Progress interface.
func (p *VerboseProgress) Step(msg string) {
	fmt.Fprintln(p.output, strings.TrimSpace(msg))
}

// Done implements the Progress interface. It's a no-op.
func (p *VerboseProgress) Done() {}

// Fail implements the Progress interface. It's a no-op.
func (p *VerboseProgress) Fail() {}

//
//
//

// NullProgress is an implementation of Progress which discards everything
// written into it and produces no output.
type NullProgress struct {
	output io.Writer
}

// NewNullProgress returns a NullProgress.
func NewNullProgress() *NullProgress {
	return &NullProgress{
		output: io.Discard,
	}
}

// Tick implements the Progress interface. It's a no-opt
func (p *NullProgress) Tick(r rune) {}

// Tick implements the Progress interface.
func (p *NullProgress) Write(buf []byte) (int, error) {
	return p.output.Write(buf)
}

// Step implements the Progress interface.
func (p *NullProgress) Step(msg string) {}

// Done implements the Progress interface. It's a no-op.
func (p *NullProgress) Done() {}

// Fail implements the Progress interface. It's a no-op.
func (p *NullProgress) Fail() {}