1// Package anim provides an animated spinner.
2package anim
3
4import (
5 "image/color"
6 "math/rand/v2"
7 "strings"
8 "sync/atomic"
9 "time"
10
11 tea "github.com/charmbracelet/bubbletea/v2"
12 "github.com/charmbracelet/lipgloss/v2"
13 "github.com/lucasb-eyer/go-colorful"
14)
15
16const (
17 fps = 20
18 initialChar = '.'
19 labelGap = " "
20 labelGapWidth = 1
21
22 // Periods of ellipsis animation speed in steps.
23 //
24 // If the FPS is 20 (50 milliseconds) this means that the ellipsis will
25 // change every 8 frames (400 milliseconds).
26 ellipsisAnimSpeed = 8
27
28 // The maximum amount of time that can pass before a character appears.
29 // This is used to create a staggered entrance effect.
30 maxBirthOffset = time.Second
31
32 // Number of frames to prerender for the animation. After this number
33 // of frames, the animation will loop. This only applies when color
34 // cycling is disabled.
35 prerenderedFrames = 10
36
37 // Default number of cycling chars.
38 defaultNumCyclingChars = 10
39)
40
41// Default colors for gradient.
42var (
43 defaultGradColorA = color.RGBA{R: 0xff, G: 0, B: 0, A: 0xff}
44 defaultGradColorB = color.RGBA{R: 0, G: 0, B: 0xff, A: 0xff}
45 defaultLabelColor = color.RGBA{R: 0xcc, G: 0xcc, B: 0xcc, A: 0xff}
46)
47
48var (
49 availableRunes = []rune("0123456789abcdefABCDEF~!@#$£€%^&*()+=_")
50 ellipsisFrames = []string{".", "..", "...", ""}
51)
52
53// Internal ID management. Used during animating to ensure that frame messages
54// are received only by spinner components that sent them.
55var lastID int64
56
57func nextID() int {
58 return int(atomic.AddInt64(&lastID, 1))
59}
60
61// StepMsg is a message type used to trigger the next step in the animation.
62type StepMsg struct{ id int }
63
64// Settings defines settings for the animation.
65type Settings struct {
66 Size int
67 Label string
68 LabelColor color.Color
69 GradColorA color.Color
70 GradColorB color.Color
71 CycleColors bool
72}
73
74// Default settings.
75const ()
76
77// Anim is a Bubble for an animated spinner.
78type Anim struct {
79 width int
80 cyclingCharWidth int
81 label []string
82 labelWidth int
83 labelColor color.Color
84 startTime time.Time
85 birthOffsets []time.Duration
86 initialFrames [][]string // frames for the initial characters
87 initialized bool
88 cyclingFrames [][]string // frames for the cycling characters
89 step int // current main frame step
90 ellipsisStep int // current ellipsis frame step
91 ellipsisFrames []string // ellipsis animation frames
92 id int
93}
94
95// New creates a new Anim instance with the specified width and label.
96func New(opts Settings) (a Anim) {
97 // Validate settings.
98 if opts.Size < 1 {
99 opts.Size = defaultNumCyclingChars
100 }
101 if colorIsUnset(opts.GradColorA) {
102 opts.GradColorA = defaultGradColorA
103 }
104 if colorIsUnset(opts.GradColorB) {
105 opts.GradColorB = defaultGradColorB
106 }
107 if colorIsUnset(opts.LabelColor) {
108 opts.LabelColor = defaultLabelColor
109 }
110
111 a.id = nextID()
112
113 a.startTime = time.Now()
114 a.cyclingCharWidth = opts.Size
115 a.labelWidth = lipgloss.Width(opts.Label)
116 a.labelColor = opts.LabelColor
117
118 // Total width of anim, in cells.
119 a.width = opts.Size
120 if opts.Label != "" {
121 a.width += labelGapWidth + lipgloss.Width(opts.Label)
122 }
123
124 // Render the label
125 a.renderLabel(opts.Label)
126
127 // Pre-generate gradient.
128 var ramp []color.Color
129 numFrames := prerenderedFrames
130 if opts.CycleColors {
131 ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
132 numFrames = a.width * 2
133 } else {
134 ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
135 }
136
137 // Pre-render initial characters.
138 a.initialFrames = make([][]string, numFrames)
139 offset := 0
140 for i := range a.initialFrames {
141 a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
142 for j := range a.initialFrames[i] {
143 if j+offset >= len(ramp) {
144 continue // skip if we run out of colors
145 }
146
147 var c color.Color
148 if j <= a.cyclingCharWidth {
149 c = ramp[j+offset]
150 } else {
151 c = opts.LabelColor
152 }
153
154 // Also prerender the initial character with Lip Gloss to avoid
155 // processing in the render loop.
156 a.initialFrames[i][j] = lipgloss.NewStyle().
157 Foreground(c).
158 Render(string(initialChar))
159 }
160 if opts.CycleColors {
161 offset++
162 }
163 }
164
165 // Prerender scrambled rune frames for the animation.
166 a.cyclingFrames = make([][]string, numFrames)
167 offset = 0
168 for i := range a.cyclingFrames {
169 a.cyclingFrames[i] = make([]string, a.width)
170 for j := range a.cyclingFrames[i] {
171 if j+offset >= len(ramp) {
172 continue // skip if we run out of colors
173 }
174
175 // Also prerender the color with Lip Gloss here to avoid processing
176 // in the render loop.
177 r := availableRunes[rand.IntN(len(availableRunes))]
178 a.cyclingFrames[i][j] = lipgloss.NewStyle().
179 Foreground(ramp[j+offset]).
180 Render(string(r))
181 }
182 if opts.CycleColors {
183 offset++
184 }
185 }
186
187 // Random assign a birth to each character for a stagged entrance effect.
188 a.birthOffsets = make([]time.Duration, a.width)
189 for i := range a.birthOffsets {
190 a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
191 }
192
193 return a
194}
195
196// SetLabel updates the label text and re-renders it.
197func (a *Anim) SetLabel(newLabel string) {
198 a.labelWidth = lipgloss.Width(newLabel)
199
200 // Update total width
201 a.width = a.cyclingCharWidth
202 if newLabel != "" {
203 a.width += labelGapWidth + a.labelWidth
204 }
205
206 // Re-render the label
207 a.renderLabel(newLabel)
208}
209
210// renderLabel renders the label with the current label color.
211func (a *Anim) renderLabel(label string) {
212 if a.labelWidth > 0 {
213 // Pre-render the label.
214 labelRunes := []rune(label)
215 a.label = make([]string, len(labelRunes))
216 for i := range a.label {
217 a.label[i] = lipgloss.NewStyle().
218 Foreground(a.labelColor).
219 Render(string(labelRunes[i]))
220 }
221
222 // Pre-render the ellipsis frames which come after the label.
223 a.ellipsisFrames = make([]string, len(ellipsisFrames))
224 for i, frame := range ellipsisFrames {
225 a.ellipsisFrames[i] = lipgloss.NewStyle().
226 Foreground(a.labelColor).
227 Render(frame)
228 }
229 } else {
230 a.label = nil
231 a.ellipsisFrames = nil
232 }
233}
234
235// Width returns the total width of the animation.
236func (a Anim) Width() (w int) {
237 w = a.width
238 if a.labelWidth > 0 {
239 w += labelGapWidth + a.labelWidth
240
241 var widestEllipsisFrame int
242 for _, f := range ellipsisFrames {
243 fw := lipgloss.Width(f)
244 if fw > widestEllipsisFrame {
245 widestEllipsisFrame = fw
246 }
247 }
248 w += widestEllipsisFrame
249 }
250 return w
251}
252
253// Init starts the animation.
254func (a Anim) Init() tea.Cmd {
255 return a.Step()
256}
257
258// Update processes animation steps (or not).
259func (a Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
260 switch msg := msg.(type) {
261 case StepMsg:
262 if msg.id != a.id {
263 // Reject messages that are not for this instance.
264 return a, nil
265 }
266
267 a.step++
268 if a.step >= len(a.cyclingFrames) {
269 a.step = 0
270 }
271
272 if a.initialized && a.labelWidth > 0 {
273 // Manage the ellipsis animation.
274 a.ellipsisStep++
275 if a.ellipsisStep >= ellipsisAnimSpeed*len(ellipsisFrames) {
276 a.ellipsisStep = 0
277 }
278 } else if !a.initialized && time.Since(a.startTime) >= maxBirthOffset {
279 a.initialized = true
280 }
281 return a, a.Step()
282 default:
283 return a, nil
284 }
285}
286
287// View renders the current state of the animation.
288func (a Anim) View() string {
289 var b strings.Builder
290 for i := range a.width {
291 switch {
292 case !a.initialized && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
293 // Birth offset not reached: render initial character.
294 b.WriteString(a.initialFrames[a.step][i])
295 case i < a.cyclingCharWidth:
296 // Render a cycling character.
297 b.WriteString(a.cyclingFrames[a.step][i])
298 case i == a.cyclingCharWidth:
299 // Render label gap.
300 b.WriteString(labelGap)
301 case i > a.cyclingCharWidth:
302 // Label.
303 b.WriteString(a.label[i-a.cyclingCharWidth-labelGapWidth])
304 }
305 }
306 // Render animated ellipsis at the end of the label if all characters
307 // have been initialized.
308 if a.initialized && a.labelWidth > 0 {
309 b.WriteString(a.ellipsisFrames[a.ellipsisStep/ellipsisAnimSpeed])
310 }
311
312 return b.String()
313}
314
315// Step is a command that triggers the next step in the animation.
316func (a Anim) Step() tea.Cmd {
317 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
318 return StepMsg{id: a.id}
319 })
320}
321
322// makeGradientRamp() returns a slice of colors blended between the given keys.
323// Blending is done as Hcl to stay in gamut.
324func makeGradientRamp(size int, stops ...color.Color) []color.Color {
325 if len(stops) < 2 {
326 return nil
327 }
328
329 points := make([]colorful.Color, len(stops))
330 for i, k := range stops {
331 points[i], _ = colorful.MakeColor(k)
332 }
333
334 numSegments := len(stops) - 1
335 if numSegments == 0 {
336 return nil
337 }
338 blended := make([]color.Color, 0, size)
339
340 // Calculate how many colors each segment should have.
341 segmentSizes := make([]int, numSegments)
342 baseSize := size / numSegments
343 remainder := size % numSegments
344
345 // Distribute the remainder across segments.
346 for i := range numSegments {
347 segmentSizes[i] = baseSize
348 if i < remainder {
349 segmentSizes[i]++
350 }
351 }
352
353 // Generate colors for each segment.
354 for i := range numSegments {
355 c1 := points[i]
356 c2 := points[i+1]
357 segmentSize := segmentSizes[i]
358
359 for j := range segmentSize {
360 if segmentSize == 0 {
361 continue
362 }
363 t := float64(j) / float64(segmentSize)
364 c := c1.BlendHcl(c2, t)
365 blended = append(blended, c)
366 }
367 }
368
369 return blended
370}
371
372func colorIsUnset(c color.Color) bool {
373 if c == nil {
374 return true
375 }
376 _, _, _, a := c.RGBA()
377 return a == 0
378}