spinner.go

  1// Package spinner provides a spinner component for Bubble Tea applications.
  2package spinner
  3
  4import (
  5	"sync/atomic"
  6	"time"
  7
  8	tea "github.com/charmbracelet/bubbletea/v2"
  9	"github.com/charmbracelet/lipgloss/v2"
 10)
 11
 12// Internal ID management. Used during animating to ensure that frame messages
 13// are received only by spinner components that sent them.
 14var lastID int64
 15
 16func nextID() int {
 17	return int(atomic.AddInt64(&lastID, 1))
 18}
 19
 20// Spinner is a set of frames used in animating the spinner.
 21type Spinner struct {
 22	Frames []string
 23	FPS    time.Duration
 24}
 25
 26// Some spinners to choose from. You could also make your own.
 27var (
 28	Line = Spinner{
 29		Frames: []string{"|", "/", "-", "\\"},
 30		FPS:    time.Second / 10, //nolint:mnd
 31	}
 32	Dot = Spinner{
 33		Frames: []string{"⣾ ", "⣽ ", "⣻ ", "⢿ ", "⡿ ", "⣟ ", "⣯ ", "⣷ "},
 34		FPS:    time.Second / 10, //nolint:mnd
 35	}
 36	MiniDot = Spinner{
 37		Frames: []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"},
 38		FPS:    time.Second / 12, //nolint:mnd
 39	}
 40	Jump = Spinner{
 41		Frames: []string{"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"},
 42		FPS:    time.Second / 10, //nolint:mnd
 43	}
 44	Pulse = Spinner{
 45		Frames: []string{"█", "▓", "▒", "░"},
 46		FPS:    time.Second / 8, //nolint:mnd
 47	}
 48	Points = Spinner{
 49		Frames: []string{"∙∙∙", "●∙∙", "∙●∙", "∙∙●"},
 50		FPS:    time.Second / 7, //nolint:mnd
 51	}
 52	Globe = Spinner{
 53		Frames: []string{"🌍", "🌎", "🌏"},
 54		FPS:    time.Second / 4, //nolint:mnd
 55	}
 56	Moon = Spinner{
 57		Frames: []string{"🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"},
 58		FPS:    time.Second / 8, //nolint:mnd
 59	}
 60	Monkey = Spinner{
 61		Frames: []string{"🙈", "🙉", "🙊"},
 62		FPS:    time.Second / 3, //nolint:mnd
 63	}
 64	Meter = Spinner{
 65		Frames: []string{
 66			"▱▱▱",
 67			"▰▱▱",
 68			"▰▰▱",
 69			"▰▰▰",
 70			"▰▰▱",
 71			"▰▱▱",
 72			"▱▱▱",
 73		},
 74		FPS: time.Second / 7, //nolint:mnd
 75	}
 76	Hamburger = Spinner{
 77		Frames: []string{"☹", "☲", "☴", "☲"},
 78		FPS:    time.Second / 3, //nolint:mnd
 79	}
 80	Ellipsis = Spinner{
 81		Frames: []string{"", ".", "..", "..."},
 82		FPS:    time.Second / 3, //nolint:mnd
 83	}
 84)
 85
 86// Model contains the state for the spinner. Use New to create new models
 87// rather than using Model as a struct literal.
 88type Model struct {
 89	// Spinner settings to use. See type Spinner.
 90	Spinner Spinner
 91
 92	// Style sets the styling for the spinner. Most of the time you'll just
 93	// want foreground and background coloring, and potentially some padding.
 94	//
 95	// For an introduction to styling with Lip Gloss see:
 96	// https://github.com/charmbracelet/lipgloss
 97	Style lipgloss.Style
 98
 99	frame int
100	id    int
101	tag   int
102}
103
104// ID returns the spinner's unique ID.
105func (m Model) ID() int {
106	return m.id
107}
108
109// New returns a model with default values.
110func New(opts ...Option) Model {
111	m := Model{
112		Spinner: Line,
113		id:      nextID(),
114	}
115
116	for _, opt := range opts {
117		opt(&m)
118	}
119
120	return m
121}
122
123// TickMsg indicates that the timer has ticked and we should render a frame.
124type TickMsg struct {
125	Time time.Time
126	tag  int
127	ID   int
128}
129
130// Update is the Tea update function.
131func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
132	switch msg := msg.(type) {
133	case TickMsg:
134		// If an ID is set, and the ID doesn't belong to this spinner, reject
135		// the message.
136		if msg.ID > 0 && msg.ID != m.id {
137			return m, nil
138		}
139
140		// If a tag is set, and it's not the one we expect, reject the message.
141		// This prevents the spinner from receiving too many messages and
142		// thus spinning too fast.
143		if msg.tag > 0 && msg.tag != m.tag {
144			return m, nil
145		}
146
147		m.frame++
148		if m.frame >= len(m.Spinner.Frames) {
149			m.frame = 0
150		}
151
152		m.tag++
153		return m, m.tick(m.id, m.tag)
154	default:
155		return m, nil
156	}
157}
158
159// View renders the model's view.
160func (m Model) View() string {
161	if m.frame >= len(m.Spinner.Frames) {
162		return "(error)"
163	}
164
165	return m.Style.Render(m.Spinner.Frames[m.frame])
166}
167
168// Tick is the command used to advance the spinner one frame. Use this command
169// to effectively start the spinner.
170func (m Model) Tick() tea.Msg {
171	return TickMsg{
172		// The time at which the tick occurred.
173		Time: time.Now(),
174
175		// The ID of the spinner that this message belongs to. This can be
176		// helpful when routing messages, however bear in mind that spinners
177		// will ignore messages that don't contain ID by default.
178		ID: m.id,
179
180		tag: m.tag,
181	}
182}
183
184func (m Model) tick(id, tag int) tea.Cmd {
185	return tea.Tick(m.Spinner.FPS, func(t time.Time) tea.Msg {
186		return TickMsg{
187			Time: t,
188			ID:   id,
189			tag:  tag,
190		}
191	})
192}
193
194// Option is used to set options in New. For example:
195//
196//	spinner := New(WithSpinner(Dot))
197type Option func(*Model)
198
199// WithSpinner is an option to set the spinner. Pass this to [Spinner.New].
200func WithSpinner(spinner Spinner) Option {
201	return func(m *Model) {
202		m.Spinner = spinner
203	}
204}
205
206// WithStyle is an option to set the spinner style. Pass this to [Spinner.New].
207func WithStyle(style lipgloss.Style) Option {
208	return func(m *Model) {
209		m.Style = style
210	}
211}