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