spinner.go

  1// Package spinner implements a spinner used to indicate processing is occurring.
  2package spinner
  3
  4import (
  5	"image/color"
  6	"strings"
  7	"sync/atomic"
  8	"time"
  9
 10	tea "charm.land/bubbletea/v2"
 11	"charm.land/lipgloss/v2"
 12	"github.com/charmbracelet/x/exp/charmtone"
 13)
 14
 15const (
 16	fps             = 24
 17	decay           = 12
 18	pauseSteps      = 48
 19	lowChar         = "ยท"
 20	highChar        = "โ”‚"
 21	ellipsisChar    = "."
 22	maxEllipsisDots = 3
 23	ellipsisFPS     = 8
 24	ellipsisPause   = 2 // frames to pause at max dots
 25)
 26
 27// Internal ID management. Used during animating to ensure that frame messages
 28// are received only by spinner components that sent them.
 29var lastID int64
 30
 31func nextID() int {
 32	return int(atomic.AddInt64(&lastID, 1))
 33}
 34
 35type Config struct {
 36	Width      int
 37	EmptyColor color.Color
 38	Blend      []color.Color
 39	LabelColor color.Color
 40}
 41
 42// DefaultConfig returns the default spinner configuration.
 43func DefaultConfig() Config {
 44	return Config{
 45		Width:      14,
 46		LabelColor: charmtone.Smoke,
 47		EmptyColor: charmtone.Charcoal,
 48		Blend: []color.Color{
 49			charmtone.Charcoal,
 50			charmtone.Charple,
 51			charmtone.Dolly,
 52		},
 53	}
 54}
 55
 56// StepMsg is a message sent to spinners to indicate it's time to update their
 57// state.
 58type StepMsg struct {
 59	ID  int
 60	tag int
 61}
 62
 63// Spinner is a spinner Bubble.
 64type Spinner struct {
 65	Label            string
 66	Config           Config
 67	id               int
 68	tag              int
 69	ellipsisStep     int
 70	index            int
 71	pause            int
 72	cells            []int
 73	maxAt            []int // frame when cell reached max height
 74	emptyChar        string
 75	blendStyles      []lipgloss.Style
 76	labelEllipsisDot string
 77}
 78
 79// NewSpinner creates a new Spinner with the given label.
 80func NewSpinner(label string) Spinner {
 81	c := DefaultConfig()
 82	blend := lipgloss.Blend1D(c.Width, c.Blend...)
 83	blendStyles := make([]lipgloss.Style, len(blend))
 84
 85	for i, s := range blend {
 86		blendStyles[i] = lipgloss.NewStyle().Foreground(s)
 87	}
 88
 89	labelStyle := lipgloss.NewStyle().Foreground(c.LabelColor)
 90
 91	return Spinner{
 92		Label:            labelStyle.Render(label),
 93		labelEllipsisDot: labelStyle.Render(ellipsisChar),
 94		Config:           c,
 95		id:               nextID(),
 96		index:            -1,
 97		cells:            make([]int, c.Width),
 98		maxAt:            make([]int, c.Width),
 99		emptyChar:        lipgloss.NewStyle().Foreground(c.EmptyColor).Render(string(lowChar)),
100		blendStyles:      blendStyles,
101	}
102}
103
104// Init initializes the spinner. It satisfies tea.Model.
105func (s Spinner) Init() tea.Cmd {
106	return nil
107}
108
109// Update updates the spinner per incoming messages. It satisfies tea.Model.
110func (s Spinner) Update(msg tea.Msg) (Spinner, tea.Cmd) {
111	if _, ok := msg.(StepMsg); ok {
112		if msg.(StepMsg).ID != s.id {
113			// Reject events from other spinners.
114			return s, nil
115		}
116
117		s.ellipsisStep++
118		if s.ellipsisStep > ellipsisFPS*(maxEllipsisDots+ellipsisPause) {
119			s.ellipsisStep = 0
120		}
121
122		if s.pause > 0 {
123			s.pause--
124		} else {
125			s.index++
126			if s.index > s.Config.Width {
127				s.pause = pauseSteps
128				s.index = -1
129			}
130
131		}
132
133		for i, c := range s.cells {
134			if s.index == i {
135				s.cells[i] = s.Config.Width - 1
136				s.maxAt[i] = s.tag
137			} else {
138				if s.maxAt[i] >= 0 && s.tag-s.maxAt[i] < decay {
139					continue
140				}
141				s.cells[i] = max(0, c-1)
142			}
143		}
144
145		s.tag++
146		return s, s.Start()
147	}
148	return s, nil
149}
150
151// Start starts the spinner animation.
152func (s Spinner) Start() tea.Cmd {
153	return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
154		return StepMsg{ID: s.id}
155	})
156}
157
158// View renders the spinner to a string. It satisfies tea.Model.
159func (s Spinner) View() string {
160	if s.Config.Width == 0 {
161		return ""
162	}
163
164	var b strings.Builder
165	for i := range s.cells {
166		if s.cells[i] == 0 {
167			b.WriteString(s.emptyChar)
168			continue
169		}
170		b.WriteString(s.blendStyles[s.cells[i]-1].Render(highChar))
171	}
172
173	if s.Label != "" {
174		b.WriteString(" ")
175		b.WriteString(s.Label)
176
177		// Draw ellipsis.
178		dots := min(s.ellipsisStep/ellipsisFPS, maxEllipsisDots)
179		for range dots {
180			b.WriteString(s.labelEllipsisDot)
181		}
182	}
183
184	return b.String()
185}