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