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) {