From 112b4fc211181db57c110bc3b117c8964076b1a5 Mon Sep 17 00:00:00 2001 From: Tim Heckman Date: Sun, 12 Dec 2021 01:27:18 -0800 Subject: [PATCH] Add support for non-TTY output targets This change introduces better handling of non-TTY output targets. Specifically, the spinner animation is disabled and the lines aren't erased. In the Non-TTY mode, the spinner is only animated on each update to the spinner data (e.g., message). In addition, this does not erase the line when rendering and instead renders the animation on a new line. --- README.md | 13 +++ go.mod | 2 +- go.sum | 2 - spinner.go | 57 ++++++++--- spinner_test.go | 249 +++++++++++++++++++++++++++++++++++------------- 5 files changed, 243 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index d0e1860..f2ca002 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,19 @@ want to change a few configuration items via method calls, you can `Pause()` the spinner first. After making the changes you can call `Unpause()`, and it will continue rendering like normal with the newly applied configuration. +#### Supporting non-TTY Output Targets +`yacspin` also has native support for non-TTY output targets. This is detected +automatically within the constructor, or can be specified via the `NotTTY` +`Config` struct field, and results in a different mode of operation. + +Specifically, when this is detected the spinner no longer uses colors, disables +the automatic spinner animation, and instead only animates the spinner when updating the +message. In addition, each animation is rendered on a new line instead of +overwriting the current line. + +This should result in human-readable output without any changes needed by +consumers, even when the system is writing to a non-TTY destination. + ## Usage ``` go get github.com/theckman/yacspin diff --git a/go.mod b/go.mod index 9e907b1..c4f7b22 100644 --- a/go.mod +++ b/go.mod @@ -6,11 +6,11 @@ require ( github.com/fatih/color v1.13.0 github.com/google/go-cmp v0.5.6 github.com/mattn/go-colorable v0.1.12 + github.com/mattn/go-isatty v0.0.14 github.com/mattn/go-runewidth v0.0.13 ) require ( - github.com/mattn/go-isatty v0.0.14 // indirect github.com/rivo/uniseg v0.2.0 // indirect golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6 // indirect ) diff --git a/go.sum b/go.sum index 8085d2e..4b374fd 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,6 @@ github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/mattn/go-colorable v0.1.9 h1:sqDoxXbdeALODt0DAeJCVp38ps9ZogZEAXjus69YV3U= github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= @@ -15,7 +14,6 @@ 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-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/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.0.0-20210927094055-39ccf1dd6fa6 h1:foEbQz/B0Oz6YIqu/69kfXPYeFQAuuMYFkjaqXzl5Wo= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/spinner.go b/spinner.go index 4984f5b..ec48349 100644 --- a/spinner.go +++ b/spinner.go @@ -49,6 +49,7 @@ import ( "errors" "fmt" "io" + "math" "os" "strings" "sync" @@ -56,6 +57,7 @@ import ( "time" "github.com/mattn/go-colorable" + "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" ) @@ -182,6 +184,12 @@ type Config struct { // StopFailColors are the colors used for the StopFail() printed line. This // respects the ColorAll field. StopFailColors []string + + // NotTTY tells the spinner that the Writer should not be treated as a TTY. + // This results in the animation being disabled, with the animation only + // happening whenever the data is updated. This mode also renders each + // update on new line, versus reusing the current line. + NotTTY bool } // Spinner is a type representing an animated CLi terminal spinner. It's @@ -196,6 +204,7 @@ type Spinner struct { cursorHidden bool suffixAutoColon bool isDumbTerm bool + isNotTTY bool spinnerAtEnd bool status *uint32 @@ -236,12 +245,17 @@ const ( statusUnpausing ) -// New creates a new unstarted spinner. +// New creates a new unstarted spinner. If stdout does not appear to be a TTY, +// this constructor implicitly sets cfg.NotTTY to true. func New(cfg Config) (*Spinner, error) { if cfg.Frequency < 1 { return nil, errors.New("cfg.Frequency must be greater than 0") } + if !isatty.IsTerminal(os.Stdout.Fd()) && !isatty.IsCygwinTerminal(os.Stdout.Fd()) { + cfg.NotTTY = true + } + s := &Spinner{ buffer: bytes.NewBuffer(make([]byte, 2048)), mu: &sync.Mutex{}, @@ -279,6 +293,11 @@ func New(cfg Config) (*Spinner, error) { // can only error if the charset is empty, and we prevent that above _ = s.CharSet(cfg.CharSet) + if cfg.NotTTY { + s.isNotTTY = true + s.isDumbTerm = true + } + if cfg.Writer == nil { cfg.Writer = colorable.NewColorableStdout() } @@ -394,6 +413,11 @@ func (s *Spinner) Start() error { s.frequencyUpdateCh = make(chan time.Duration, 4) s.dataUpdateCh, s.cancelCh = make(chan struct{}, 1), make(chan struct{}, 1) + if s.isNotTTY { + // hack to prevent the animation from running if not a TTY + s.frequency = time.Duration(math.MaxInt64) + } + s.mu.Unlock() // because of the atomic swap above, we know it's safe to mutate these @@ -568,14 +592,15 @@ func (s *Spinner) painter(cancel, dataUpdate, pause <-chan struct{}, done chan<- case <-timer.C: lastTick = time.Now() - s.paintUpdate(timer, false) + s.paintUpdate(timer, true) case <-pause: <-s.unpauseCh close(s.unpausedCh) case <-dataUpdate: - s.paintUpdate(timer, true) + // if this is not a TTY: animate the spinner on the data update + s.paintUpdate(timer, s.isNotTTY) case frequency := <-frequencyUpdate: handleFrequencyUpdate(frequency, timer, lastTick) @@ -592,7 +617,7 @@ func (s *Spinner) painter(cancel, dataUpdate, pause <-chan struct{}, done chan<- } } -func (s *Spinner) paintUpdate(timer *time.Timer, dataUpdate bool) { +func (s *Spinner) paintUpdate(timer *time.Timer, animate bool) { s.mu.Lock() p := s.prefix @@ -603,7 +628,7 @@ func (s *Spinner) paintUpdate(timer *time.Timer, dataUpdate bool) { d := s.frequency index := s.index - if !dataUpdate { + if animate { s.index++ if s.index == len(s.chars) { @@ -635,7 +660,7 @@ func (s *Spinner) paintUpdate(timer *time.Timer, dataUpdate bool) { } } - if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, s.colorAll, s.spinnerAtEnd, false, cFn); err != nil { + if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, s.colorAll, s.spinnerAtEnd, false, s.isNotTTY, cFn); err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } } else { @@ -643,7 +668,7 @@ func (s *Spinner) paintUpdate(timer *time.Timer, dataUpdate bool) { panic(fmt.Sprintf("failed to erase line: %v", err)) } - n, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, false, s.spinnerAtEnd, false, fmt.Sprintf) + n, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, false, s.spinnerAtEnd, false, s.isNotTTY, fmt.Sprintf) if err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } @@ -657,7 +682,7 @@ func (s *Spinner) paintUpdate(timer *time.Timer, dataUpdate bool) { } } - if !dataUpdate { + if animate { timer.Reset(d) } } @@ -700,7 +725,7 @@ func (s *Spinner) paintStop(chanOk bool) { if c.Size > 0 || len(m) > 0 { // paint the line with a newline as it's the final line - if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, s.colorAll, s.spinnerAtEnd, true, cFn); err != nil { + if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, s.colorAll, s.spinnerAtEnd, true, s.isNotTTY, cFn); err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } } @@ -710,7 +735,7 @@ func (s *Spinner) paintStop(chanOk bool) { } if c.Size > 0 || len(m) > 0 { - if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, false, s.spinnerAtEnd, true, fmt.Sprintf); err != nil { + if _, err := paint(s.buffer, mw, c, p, m, suf, s.suffixAutoColon, false, s.spinnerAtEnd, true, s.isNotTTY, fmt.Sprintf); err != nil { panic(fmt.Sprintf("failed to paint line: %v", err)) } } @@ -733,6 +758,10 @@ func erase(w io.Writer) error { // eraseDumbTerm clears the line on dumb terminals func (s *Spinner) eraseDumbTerm(w io.Writer) error { + if s.isNotTTY { + return nil + } + clear := "\r" + strings.Repeat(" ", s.lastPrintLen) + "\r" _, err := fmt.Fprint(w, clear) @@ -758,7 +787,7 @@ func padChar(char character, maxWidth int) string { // paint writes a single line to the w, using the provided character, message, // and color function -func paint(w io.Writer, maxWidth int, char character, prefix, message, suffix string, suffixAutoColon, colorAll, spinnerAtEnd, finalPaint bool, colorFn func(format string, a ...interface{}) string) (int, error) { +func paint(w io.Writer, maxWidth int, char character, prefix, message, suffix string, suffixAutoColon, colorAll, spinnerAtEnd, finalPaint, notTTY bool, colorFn func(format string, a ...interface{}) string) (int, error) { var output string switch char.Size { @@ -797,7 +826,7 @@ func paint(w io.Writer, maxWidth int, char character, prefix, message, suffix st output = fmt.Sprintf("%s%s%s%s", prefix, colorFn(c), suffix, message) } - if finalPaint { + if finalPaint || notTTY { output += "\n" } @@ -810,6 +839,10 @@ func (s *Spinner) Frequency(d time.Duration) error { return errors.New("duration must be greater than 0") } + if s.isNotTTY { + return nil + } + s.mu.Lock() defer s.mu.Unlock() diff --git a/spinner_test.go b/spinner_test.go index eab373a..ebb47b6 100644 --- a/spinner_test.go +++ b/spinner_test.go @@ -4,6 +4,7 @@ import ( "bytes" "fmt" "io" + "math" "os" "strings" "sync" @@ -407,10 +408,11 @@ func TestSpinner_notifyDataChange(t *testing.T) { func TestSpinner_Frequency(t *testing.T) { tests := []struct { - name string - input time.Duration - ch chan time.Duration - err string + name string + input time.Duration + isNotTTY bool + ch chan time.Duration + err string }{ { name: "invalid", @@ -427,6 +429,12 @@ func TestSpinner_Frequency(t *testing.T) { input: 42, ch: make(chan time.Duration, 1), }, + { + name: "is_not_tty", + input: 42, + isNotTTY: true, + ch: make(chan time.Duration, 1), + }, } for _, tt := range tests { @@ -437,6 +445,7 @@ func TestSpinner_Frequency(t *testing.T) { mu: &sync.Mutex{}, frequency: 0, frequencyUpdateCh: tt.ch, + isNotTTY: tt.isNotTTY, } tmr := time.NewTimer(2 * time.Second) @@ -470,13 +479,17 @@ func TestSpinner_Frequency(t *testing.T) { t.Errorf("channel receive got = %s, want %s", got, tt.input) } default: - t.Fatal("notification channel had no messages") + if !tt.isNotTTY { + t.Fatal("notification channel had no messages") + } } } - got := spinner.frequency - if got != tt.input { - t.Errorf("got = %s, want %s", got, tt.input) + if !tt.isNotTTY { + got := spinner.frequency + if got != tt.input { + t.Errorf("got = %s, want %s", got, tt.input) + } } }) } @@ -760,6 +773,21 @@ func TestSpinner_Start(t *testing.T) { stopFailMsg: "stop fail msg", }, }, + { + name: "spinner_not_tty", + spinner: &Spinner{ + buffer: &bytes.Buffer{}, + status: uint32Ptr(statusStopped), + mu: &sync.Mutex{}, + frequency: time.Millisecond, + colorFn: fmt.Sprintf, + stopColorFn: fmt.Sprintf, + stopFailColorFn: fmt.Sprintf, + stopMsg: "stop msg", + stopFailMsg: "stop fail msg", + isNotTTY: true, + }, + }, } for _, tt := range tests { @@ -789,6 +817,10 @@ func TestSpinner_Start(t *testing.T) { if buf.Len() == 0 { t.Fatal("painter did not write data") } + + if max := time.Duration(math.MaxInt64); tt.spinner.isNotTTY && tt.spinner.frequency != max { + t.Fatalf("tt.spinner.duration = %s, want %s", tt.spinner.frequency, max) + } }) } } @@ -1164,10 +1196,10 @@ func TestSpinner_paintUpdate(t *testing.T) { tm := time.NewTimer(10 * time.Millisecond) - tt.spinner.paintUpdate(tm, false) - tt.spinner.paintUpdate(tm, false) + tt.spinner.paintUpdate(tm, true) tt.spinner.paintUpdate(tm, true) tt.spinner.paintUpdate(tm, false) + tt.spinner.paintUpdate(tm, true) tm.Stop() got := buf.String() @@ -1494,82 +1526,169 @@ func Test_setToCharSlice(t *testing.T) { } func TestSpinner_painter(t *testing.T) { - if testing.Short() { - t.Skip("skipping test in short mode.") - } + t.Run("animated", func(t *testing.T) { + if testing.Short() { + t.Skip("skipping test in short mode.") + } - const want = "\r\033[K\ray msg\r\033[K\ray othermsg\r\033[K\raz msg\r\033[K\ray msg\r\x1b[K\rav stop\n" + const want = "\r\033[K\ray msg\r\033[K\ray othermsg\r\033[K\raz msg\r\033[K\ray msg\r\x1b[K\rav stop\n" + + buf := &bytes.Buffer{} + + cancel, done, dataUpdate, pause := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + frequencyUpdate := make(chan time.Duration, 1) + + spinner := &Spinner{ + buffer: &bytes.Buffer{}, + mu: &sync.Mutex{}, + writer: buf, + prefix: "a", + message: "msg", + suffix: " ", + maxWidth: 1, + colorFn: fmt.Sprintf, + chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, + stopColorFn: fmt.Sprintf, + stopMsg: "stop", + stopChar: character{Value: "v", Size: 1}, + frequency: 2000 * time.Millisecond, + cancelCh: cancel, + doneCh: done, + dataUpdateCh: dataUpdate, + frequencyUpdateCh: frequencyUpdate, + } - buf := &bytes.Buffer{} + go spinner.painter(cancel, dataUpdate, pause, done, frequencyUpdate) - cancel, done, dataUpdate, pause := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) - frequencyUpdate := make(chan time.Duration, 1) - - spinner := &Spinner{ - buffer: &bytes.Buffer{}, - mu: &sync.Mutex{}, - writer: buf, - prefix: "a", - message: "msg", - suffix: " ", - maxWidth: 1, - colorFn: fmt.Sprintf, - chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, - stopColorFn: fmt.Sprintf, - stopMsg: "stop", - stopChar: character{Value: "v", Size: 1}, - frequency: 2000 * time.Millisecond, - cancelCh: cancel, - doneCh: done, - dataUpdateCh: dataUpdate, - frequencyUpdateCh: frequencyUpdate, - } + time.Sleep(500 * time.Millisecond) + + spinner.mu.Lock() - go spinner.painter(cancel, dataUpdate, pause, done, frequencyUpdate) + spinner.message = "othermsg" + spinner.dataUpdateCh <- struct{}{} - time.Sleep(500 * time.Millisecond) + spinner.mu.Unlock() - spinner.mu.Lock() + time.Sleep(500 * time.Millisecond) - spinner.message = "othermsg" - spinner.dataUpdateCh <- struct{}{} + spinner.unpauseCh, spinner.unpausedCh = make(chan struct{}), make(chan struct{}) + pause <- struct{}{} - spinner.mu.Unlock() + close(spinner.unpauseCh) + _, ok := <-spinner.unpausedCh - time.Sleep(500 * time.Millisecond) + if ok { + t.Fatal("unexpected successful channel receive") + } - spinner.unpauseCh, spinner.unpausedCh = make(chan struct{}), make(chan struct{}) - pause <- struct{}{} + spinner.unpauseCh = nil + spinner.unpausedCh = nil - close(spinner.unpauseCh) - _, ok := <-spinner.unpausedCh + spinner.mu.Lock() - if ok { - t.Fatal("unexpected successful channel receive") - } + spinner.message = "msg" + spinner.frequency = 1000 * time.Millisecond + frequencyUpdate <- 1000 * time.Millisecond - spinner.unpauseCh = nil - spinner.unpausedCh = nil + spinner.mu.Unlock() - spinner.mu.Lock() + time.Sleep(1200 * time.Millisecond) - spinner.message = "msg" - spinner.frequency = 1000 * time.Millisecond - frequencyUpdate <- 1000 * time.Millisecond + cancel <- struct{}{} - spinner.mu.Unlock() + <-done - time.Sleep(1200 * time.Millisecond) + got := buf.String() - cancel <- struct{}{} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("output differs: (-want / +got)\n%s", diff) + } + }) + + t.Run("no_tty", func(t *testing.T) { + const want = "ay msg\naz othermsg\nay msg\naz msg\nav stop\n" + + buf := &bytes.Buffer{} + + cancel, done, dataUpdate, pause := make(chan struct{}), make(chan struct{}), make(chan struct{}), make(chan struct{}) + frequencyUpdate := make(chan time.Duration, 1) + + spinner := &Spinner{ + buffer: &bytes.Buffer{}, + mu: &sync.Mutex{}, + writer: buf, + prefix: "a", + message: "msg", + suffix: " ", + maxWidth: 1, + colorFn: fmt.Sprintf, + chars: []character{{Value: "y", Size: 1}, {Value: "z", Size: 1}}, + stopColorFn: fmt.Sprintf, + stopMsg: "stop", + stopChar: character{Value: "v", Size: 1}, + frequency: time.Duration(math.MaxInt64), + cancelCh: cancel, + doneCh: done, + dataUpdateCh: dataUpdate, + frequencyUpdateCh: frequencyUpdate, + isNotTTY: true, + isDumbTerm: true, + } - <-done + go spinner.painter(cancel, dataUpdate, pause, done, frequencyUpdate) - got := buf.String() + time.Sleep(100 * time.Millisecond) - if diff := cmp.Diff(want, got); diff != "" { - t.Fatalf("output differs: (-want / +got)\n%s", diff) - } + spinner.mu.Lock() + + spinner.message = "othermsg" + spinner.dataUpdateCh <- struct{}{} + + spinner.mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + spinner.unpauseCh, spinner.unpausedCh = make(chan struct{}), make(chan struct{}) + pause <- struct{}{} + + close(spinner.unpauseCh) + _, ok := <-spinner.unpausedCh + + if ok { + t.Fatal("unexpected successful channel receive") + } + + spinner.unpauseCh = nil + spinner.unpausedCh = nil + + spinner.mu.Lock() + + spinner.message = "msg" + spinner.dataUpdateCh <- struct{}{} + + spinner.mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + spinner.mu.Lock() + + spinner.message = "msg" + spinner.dataUpdateCh <- struct{}{} + + spinner.mu.Unlock() + + time.Sleep(100 * time.Millisecond) + + cancel <- struct{}{} + + <-done + + got := buf.String() + + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("output differs: (-want / +got)\n%s", diff) + } + }) } func TestSpinnerStatus_String(t *testing.T) {