A very simple terminal spinner
0

Configure Feed

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

Working spinner (#1)

authored by

Tom Fleet and committed by
GitHub
(Apr 15, 2025, 5:03 PM +0100) ffdc4bfc 9f18a8f1

+124 -10
+2
codecov.yml
··· 1 + ignore: 2 + - examples # Demo programs to showcase the library, coverage tracking not needed
+1 -1
go.mod
··· 1 1 module github.com/FollowTheProcess/spin 2 2 3 - go 1.24 3 + go 1.24
+78 -4
spin.go
··· 1 - // Package spin is a placeholder for something cool. 1 + // Package spin provides a simple, easy to use terminal spinner with configurable styles 2 + // to provide progress information in command line applications. 2 3 package spin 3 4 4 - // Hello returns a welcome message for the project. 5 - func Hello() string { 6 - return "Hello spin" 5 + import ( 6 + "fmt" 7 + "io" 8 + "sync" 9 + "sync/atomic" 10 + "time" 11 + ) 12 + 13 + const ( 14 + // frameRate is the rate at which spinner frames are rendered. 15 + frameRate = 100 * time.Millisecond 16 + 17 + // erase is the ANSI code for erasing the current line and resetting the cursor 18 + // position back to the start of the line. 19 + // 20 + // See https://gist.github.com/fnky/458719343aabd01cfb17a3a4f7296797#erase-functions. 21 + erase = "\r\x1b[K" 22 + ) 23 + 24 + // Spinner contains the spinner state. 25 + type Spinner struct { 26 + w io.Writer // Where to draw the spinner 27 + stop chan struct{} // Signal for the spinner to stop 28 + msg string // Message to display during spinning e.g. "Loading" 29 + wg sync.WaitGroup // Manages the rendering goroutine 30 + running atomic.Bool // Whether the spinner is currently running 31 + } 32 + 33 + // New returns a new [Spinner]. 34 + func New(w io.Writer, msg string) *Spinner { 35 + return &Spinner{ 36 + w: w, 37 + stop: make(chan struct{}), 38 + msg: msg, 39 + } 40 + } 41 + 42 + // Start starts the spinner animation. 43 + func (s *Spinner) Start() { 44 + if s.running.Load() { 45 + // If it's already running, we have nothing to do 46 + return 47 + } 48 + 49 + s.running.Store(true) 50 + 51 + s.wg.Add(1) 52 + go func() { 53 + // Store the frames and the index locally so no need for synchronisation 54 + frames := [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} 55 + current := 0 56 + defer s.wg.Done() 57 + for { 58 + select { 59 + case <-s.stop: 60 + return 61 + case <-time.Tick(frameRate): 62 + fmt.Fprintf(s.w, "%s%c %s...", erase, frames[current], s.msg) 63 + current = (current + 1) % len(frames) 64 + } 65 + } 66 + }() 67 + } 68 + 69 + // Stop halts the spinner animation. 70 + func (s *Spinner) Stop() { 71 + if !s.running.Load() { 72 + // If it's not already running, there's nothing to do 73 + return 74 + } 75 + 76 + s.stop <- struct{}{} // Signal the stop 77 + s.wg.Wait() // Wait for the render goroutine to return 78 + s.running.Store(false) 79 + 80 + fmt.Fprint(s.w, erase) // Erase the line 7 81 }
+24 -5
spin_test.go
··· 1 1 package spin_test 2 2 3 3 import ( 4 + "bytes" 5 + "strings" 4 6 "testing" 7 + "time" 5 8 6 9 "github.com/FollowTheProcess/spin" 7 10 ) 8 11 9 - func TestHello(t *testing.T) { 10 - got := spin.Hello() 11 - want := "Hello spin" 12 + func TestSpinner(t *testing.T) { 13 + buf := &bytes.Buffer{} 12 14 13 - if got != want { 14 - t.Errorf("got %s, wanted %s", got, want) 15 + spinner := spin.New(buf, "Testing") 16 + spinner.Start() 17 + time.Sleep(300 * time.Millisecond) 18 + spinner.Stop() 19 + 20 + got := buf.String() 21 + 22 + frames := [...]rune{'⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'} 23 + 24 + found := false 25 + for _, frame := range frames { 26 + if strings.Contains(got, string(frame)) { 27 + found = true 28 + break 29 + } 30 + } 31 + 32 + if !found { 33 + t.Fatalf("expected output to contain a spinner frame:\n\n%s\n", got) 15 34 } 16 35 }
+19
examples/basic/main.go
··· 1 + package main 2 + 3 + import ( 4 + "os" 5 + "time" 6 + 7 + "github.com/FollowTheProcess/spin" 8 + ) 9 + 10 + const duration = 2 * time.Second 11 + 12 + func main() { 13 + spinner := spin.New(os.Stdout, "Digesting") 14 + 15 + spinner.Start() 16 + defer spinner.Stop() 17 + 18 + time.Sleep(duration) 19 + }