···1818 s.frameStyle = style
1919 }
2020}
2121+2222+// WithForceEnabled bypasses the terminal check, causing the spinner to render
2323+// even when the writer is not a TTY. Useful for CI environments or testing.
2424+func WithForceEnabled() Option {
2525+ return func(s *Spinner) {
2626+ s.forceEnabled = true
2727+ }
2828+}
+20-20
spin.go
···3737 stop chan struct{} // Signal for the spinner to stop
3838 msg string // Message to display during spinning e.g. "Loading"
3939 wg sync.WaitGroup // Manages the rendering goroutine
4040- running atomic.Bool // Whether the spinner is currently running
4140 messageStyle hue.Style // Colour/style for the spinner message
4241 frameStyle hue.Style // Colour/style for the spinner animation
4242+ running atomic.Bool // Whether the spinner is currently running
4343+ forceEnabled bool // Bypass terminal check (useful for testing and CI)
4344}
44454546// New returns a new [Spinner].
4647func New(w io.Writer, msg string, options ...Option) *Spinner {
4748 spinner := &Spinner{
4849 w: w,
4949- stop: make(chan struct{}),
5050 msg: msg,
5151 messageStyle: defaultMessageStyle,
5252 frameStyle: defaultFrameStyle,
···61616262// Start starts the spinner animation.
6363func (s *Spinner) Start() {
6464- if s.running.Load() || !isTerminal(s.w) {
6565- // If it's already running, or if it's not hooked up to a terminal
6666- // there's nothing for us to do
6464+ if !s.forceEnabled && !isTerminal(s.w) {
6565+ // Not hooked up to a terminal and not force-enabled; nothing to do.
6766 return
6867 }
69687070- s.running.Store(true)
6969+ if !s.running.CompareAndSwap(false, true) {
7070+ // Already running; a concurrent or duplicate Start call is a no-op.
7171+ return
7272+ }
7373+7474+ s.stop = make(chan struct{})
71757276 s.wg.Go(func() {
7373- // Store the frames and the index locally so no need for synchronisation
7777+ // Store the frames and the index locally so no need for synchronisation.
7478 frames := [...]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
7579 current := 0
8080+ ticker := time.NewTicker(frameRate)
8181+ defer ticker.Stop()
7682 for {
7783 select {
7884 case <-s.stop:
7985 return
8080- case <-time.Tick(frameRate):
8686+ case <-ticker.C:
8187 fmt.Fprintf(s.w, "%s%s %s...", erase, s.frameStyle.Text(frames[current]), s.messageStyle.Text(s.msg))
8288 current = (current + 1) % len(frames)
8389 }
···87938894// Stop halts the spinner animation.
8995func (s *Spinner) Stop() {
9090- if !s.running.Load() {
9191- // If it's not already running, there's nothing to do
9696+ if !s.running.CompareAndSwap(true, false) {
9797+ // Not running; a concurrent or duplicate Stop call is a no-op.
9298 return
9399 }
941009595- s.stop <- struct{}{} // Signal the stop
9696- s.wg.Wait() // Wait for the render goroutine to return
9797- s.running.Store(false)
101101+ close(s.stop) // Signal the goroutine to stop.
102102+ s.wg.Wait() // Wait for the render goroutine to return.
981039999- fmt.Fprint(s.w, erase) // Erase the line
104104+ fmt.Fprint(s.w, erase) // Erase the line.
100105}
101106102107// Do runs the given function behind a spinner, automatically starting
···107112 fn()
108113}
109114110110-// isTerminal returns whether w points to an [*os.File] and whether
111111-// that is itself a tty.
115115+// isTerminal returns whether w points to an [*os.File] that is itself a tty.
112116func isTerminal(w io.Writer) bool {
113117 file, ok := w.(*os.File)
114118 if !ok {
115115- return false
116116- }
117117-118118- if file == nil {
119119 return false
120120 }
121121
+132
spin_test.go
···11+package spin_test
22+33+import (
44+ "bytes"
55+ "strings"
66+ "testing"
77+ "testing/synctest"
88+ "time"
99+1010+ "go.followtheprocess.codes/spin"
1111+)
1212+1313+const eraseSeq = "\r\x1b[K"
1414+1515+func TestSpinnerOutput(t *testing.T) {
1616+ tests := []struct {
1717+ name string
1818+ msg string
1919+ }{
2020+ {name: "short message", msg: "Loading"},
2121+ {name: "longer message", msg: "Processing data"},
2222+ }
2323+ for _, tt := range tests {
2424+ t.Run(tt.name, func(t *testing.T) {
2525+ synctest.Test(t, func(t *testing.T) {
2626+ var buf bytes.Buffer
2727+ s := spin.New(&buf, tt.msg, spin.WithForceEnabled())
2828+ s.Start()
2929+ time.Sleep(250 * time.Millisecond)
3030+ synctest.Wait()
3131+ s.Stop()
3232+3333+ output := buf.String()
3434+ if !strings.Contains(output, tt.msg) {
3535+ t.Errorf("output %q does not contain message %q", output, tt.msg)
3636+ }
3737+ if !strings.HasSuffix(output, eraseSeq) {
3838+ t.Errorf("output %q does not end with ANSI erase sequence", output)
3939+ }
4040+ })
4141+ })
4242+ }
4343+}
4444+4545+func TestSpinnerStopWhenNotRunningIsNoOp(t *testing.T) {
4646+ var buf bytes.Buffer
4747+ s := spin.New(&buf, "Loading", spin.WithForceEnabled())
4848+ s.Stop() // must not panic or write anything
4949+5050+ if buf.Len() != 0 {
5151+ t.Errorf("Stop() on unstarted spinner wrote output: %q", buf.String())
5252+ }
5353+}
5454+5555+func TestSpinnerStopCalledTwiceIsNoOp(t *testing.T) {
5656+ synctest.Test(t, func(t *testing.T) {
5757+ var buf bytes.Buffer
5858+ s := spin.New(&buf, "Loading", spin.WithForceEnabled())
5959+ s.Start()
6060+ time.Sleep(250 * time.Millisecond)
6161+ synctest.Wait()
6262+ s.Stop()
6363+6464+ outputAfterFirst := buf.String()
6565+6666+ s.Stop() // second Stop must not write anything extra or block
6767+6868+ if buf.String() != outputAfterFirst {
6969+ t.Errorf("second Stop() wrote additional output: %q", buf.String()[len(outputAfterFirst):])
7070+ }
7171+ })
7272+}
7373+7474+func TestSpinnerStartWhenAlreadyRunningIsNoOp(t *testing.T) {
7575+ synctest.Test(t, func(t *testing.T) {
7676+ var buf bytes.Buffer
7777+ s := spin.New(&buf, "Loading", spin.WithForceEnabled())
7878+ s.Start()
7979+ s.Start() // second call must not start a second goroutine
8080+ time.Sleep(250 * time.Millisecond)
8181+ synctest.Wait()
8282+ s.Stop() // must not deadlock
8383+8484+ // A single erase sequence must appear at the end — not multiple.
8585+ if output := buf.String(); !strings.HasSuffix(output, eraseSeq) {
8686+ t.Errorf("output %q does not end with a single erase sequence after double Start", output)
8787+ }
8888+ })
8989+}
9090+9191+func TestSpinnerDoExecutesFunction(t *testing.T) {
9292+ var buf bytes.Buffer
9393+ s := spin.New(&buf, "Loading", spin.WithForceEnabled())
9494+9595+ ran := false
9696+ s.Do(func() { ran = true })
9797+9898+ if !ran {
9999+ t.Error("Do() did not execute the provided function")
100100+ }
101101+}
102102+103103+func TestSpinnerDoStartsAndStopsAroundFunction(t *testing.T) {
104104+ synctest.Test(t, func(t *testing.T) {
105105+ var buf bytes.Buffer
106106+ s := spin.New(&buf, "Loading", spin.WithForceEnabled())
107107+108108+ s.Do(func() { time.Sleep(250 * time.Millisecond) })
109109+110110+ output := buf.String()
111111+ if !strings.Contains(output, "Loading") {
112112+ t.Errorf("output %q does not contain message", output)
113113+ }
114114+ if !strings.HasSuffix(output, eraseSeq) {
115115+ t.Errorf("output %q does not end with erase sequence", output)
116116+ }
117117+ })
118118+}
119119+120120+func TestSpinnerNonTerminalWriterProducesNoOutput(t *testing.T) {
121121+ synctest.Test(t, func(t *testing.T) {
122122+ var buf bytes.Buffer
123123+ s := spin.New(&buf, "Loading") // no WithForceEnabled — Start is a no-op
124124+ s.Start()
125125+ time.Sleep(250 * time.Millisecond)
126126+ s.Stop()
127127+128128+ if buf.Len() != 0 {
129129+ t.Errorf("non-terminal writer got output: %q", buf.String())
130130+ }
131131+ })
132132+}