A very simple terminal spinner
0

Configure Feed

Select the types of activity you want to include in your feed.

Add terminal auto-detection (#6)

* Add terminal auto-detection

* Fix docs

* Update deps

* Turn codecov off

* nudge

authored by

Tom Fleet and committed by
GitHub
(Apr 19, 2025, 1:25 PM +0100) a6aa0bef cd1baaad

+30 -118
-1
README.md
··· 5 5 [![Go Report Card](https://goreportcard.com/badge/github.com/FollowTheProcess/spin)](https://goreportcard.com/report/github.com/FollowTheProcess/spin) 6 6 [![GitHub](https://img.shields.io/github/v/release/FollowTheProcess/spin?logo=github&sort=semver)](https://github.com/FollowTheProcess/spin) 7 7 [![CI](https://github.com/FollowTheProcess/spin/workflows/CI/badge.svg)](https://github.com/FollowTheProcess/spin/actions?query=workflow%3ACI) 8 - [![codecov](https://codecov.io/gh/FollowTheProcess/spin/branch/main/graph/badge.svg)](https://codecov.io/gh/FollowTheProcess/spin) 9 8 10 9 A very simple terminal spinner 11 10
-2
codecov.yml
··· 1 - ignore: 2 - - examples # Demo programs to showcase the library, coverage tracking not needed
+4 -4
go.mod
··· 2 2 3 3 go 1.24 4 4 5 - require github.com/FollowTheProcess/hue v0.5.2 6 - 7 5 require ( 8 - golang.org/x/sys v0.31.0 // indirect 9 - golang.org/x/term v0.30.0 // indirect 6 + github.com/FollowTheProcess/hue v0.5.2 7 + golang.org/x/term v0.31.0 10 8 ) 9 + 10 + require golang.org/x/sys v0.32.0 // indirect
+4
go.sum
··· 2 2 github.com/FollowTheProcess/hue v0.5.2/go.mod h1:5FD2UrxTzWi0Uc63w8ndsjqPrH4xn3Q7k7vEpINqEP4= 3 3 golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 4 4 golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 5 + golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= 6 + golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 5 7 golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 6 8 golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 9 + golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= 10 + golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
+20 -2
spin.go
··· 5 5 import ( 6 6 "fmt" 7 7 "io" 8 + "os" 8 9 "sync" 9 10 "sync/atomic" 10 11 "time" 11 12 12 13 "github.com/FollowTheProcess/hue" 14 + "golang.org/x/term" 13 15 ) 14 16 15 17 const ( ··· 59 61 60 62 // Start starts the spinner animation. 61 63 func (s *Spinner) Start() { 62 - if s.running.Load() { 63 - // If it's already running, we have nothing to do 64 + if s.running.Load() || !isTerminal(s.w) { 65 + // If it's already running, or if it's not hooked up to a terminal 66 + // there's nothing for us to do 64 67 return 65 68 } 66 69 ··· 104 107 s.Start() 105 108 defer s.Stop() 106 109 fn() 110 + } 111 + 112 + // isTerminal returns whether w points to an [*os.File] and whether 113 + // that is itself a tty. 114 + func isTerminal(w io.Writer) bool { 115 + file, ok := w.(*os.File) 116 + if !ok { 117 + return false 118 + } 119 + 120 + if file == nil { 121 + return false 122 + } 123 + 124 + return term.IsTerminal(int(file.Fd())) 107 125 }
-109
spin_test.go
··· 1 - package spin_test 2 - 3 - import ( 4 - "bytes" 5 - "strings" 6 - "testing" 7 - "time" 8 - 9 - "github.com/FollowTheProcess/hue" 10 - "github.com/FollowTheProcess/spin" 11 - ) 12 - 13 - var frames = [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} 14 - 15 - func TestSpinner(t *testing.T) { 16 - buf := &bytes.Buffer{} 17 - 18 - spinner := spin.New(buf, "Testing", spin.FrameStyle(hue.Yellow), spin.MessageStyle(hue.Blue)) 19 - spinner.Start() 20 - time.Sleep(300 * time.Millisecond) 21 - spinner.Stop() 22 - 23 - got := buf.String() 24 - 25 - found := false 26 - for _, frame := range frames { 27 - if strings.Contains(got, frame) { 28 - found = true 29 - break 30 - } 31 - } 32 - 33 - if !found { 34 - t.Fatalf("expected output to contain a spinner frame:\n\n%s\n", got) 35 - } 36 - } 37 - 38 - func TestDo(t *testing.T) { 39 - buf := &bytes.Buffer{} 40 - 41 - spinner := spin.New(buf, "Testing", spin.FrameStyle(hue.Yellow), spin.MessageStyle(hue.Blue)) 42 - spinner.Do(func() { 43 - time.Sleep(300 * time.Millisecond) 44 - }) 45 - 46 - got := buf.String() 47 - 48 - found := false 49 - for _, frame := range frames { 50 - if strings.Contains(got, frame) { 51 - found = true 52 - break 53 - } 54 - } 55 - 56 - if !found { 57 - t.Fatalf("expected output to contain a spinner frame:\n\n%s\n", got) 58 - } 59 - } 60 - 61 - func TestDoubleStart(t *testing.T) { 62 - buf := &bytes.Buffer{} 63 - 64 - spinner := spin.New(buf, "Working") 65 - spinner.Start() 66 - spinner.Start() // This one shouldn't do anything 67 - 68 - time.Sleep(300 * time.Millisecond) 69 - spinner.Stop() 70 - 71 - got := buf.String() 72 - 73 - found := false 74 - for _, frame := range frames { 75 - if strings.Contains(got, frame) { 76 - found = true 77 - break 78 - } 79 - } 80 - 81 - if !found { 82 - t.Fatalf("expected output to contain a spinner frame:\n\n%s\n", got) 83 - } 84 - } 85 - 86 - func TestDoubleStop(t *testing.T) { 87 - buf := &bytes.Buffer{} 88 - 89 - spinner := spin.New(buf, "Working") 90 - spinner.Start() 91 - 92 - time.Sleep(300 * time.Millisecond) 93 - spinner.Stop() 94 - spinner.Stop() // This one shouldn't do anything 95 - 96 - got := buf.String() 97 - 98 - found := false 99 - for _, frame := range frames { 100 - if strings.Contains(got, frame) { 101 - found = true 102 - break 103 - } 104 - } 105 - 106 - if !found { 107 - t.Fatalf("expected output to contain a spinner frame:\n\n%s\n", got) 108 - } 109 - }
+2
.github/workflows/CI.yml
··· 18 18 permissions: 19 19 contents: read 20 20 uses: FollowTheProcess/ci/.github/workflows/Go.yml@v2 21 + with: 22 + codecov: false