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}