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