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}
98
99// Default settings.
100const ()
101
102// Anim is a Bubble for an animated spinner.
103type Anim struct {
104 width int
105 cyclingCharWidth int
106 label *csync.Slice[string]
107 labelWidth int
108 labelColor color.Color
109 startTime time.Time
110 birthOffsets []time.Duration
111 initialFrames [][]string // frames for the initial characters
112 initialized atomic.Bool
113 cyclingFrames [][]string // frames for the cycling characters
114 step atomic.Int64 // current main frame step
115 ellipsisStep atomic.Int64 // current ellipsis frame step
116 ellipsisFrames *csync.Slice[string] // ellipsis animation frames
117 id int
118}
119
120// New creates a new Anim instance with the specified width and label.
121func New(opts Settings) *Anim {
122 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 cached, exists := animCacheMap.Get(cacheKey)
145
146 if exists {
147 // Use cached values
148 a.width = cached.width
149 a.labelWidth = cached.labelWidth
150 a.label = csync.NewSliceFrom(cached.label)
151 a.ellipsisFrames = csync.NewSliceFrom(cached.ellipsisFrames)
152 a.initialFrames = cached.initialFrames
153 a.cyclingFrames = cached.cyclingFrames
154 } else {
155 // Generate new values and cache them
156 a.labelWidth = lipgloss.Width(opts.Label)
157
158 // Total width of anim, in cells.
159 a.width = opts.Size
160 if opts.Label != "" {
161 a.width += labelGapWidth + lipgloss.Width(opts.Label)
162 }
163
164 // Render the label
165 a.renderLabel(opts.Label)
166
167 // Pre-generate gradient.
168 var ramp []color.Color
169 numFrames := prerenderedFrames
170 if opts.CycleColors {
171 ramp = makeGradientRamp(a.width*3, opts.GradColorA, opts.GradColorB, opts.GradColorA, opts.GradColorB)
172 numFrames = a.width * 2
173 } else {
174 ramp = makeGradientRamp(a.width, opts.GradColorA, opts.GradColorB)
175 }
176
177 // Pre-render initial characters.
178 a.initialFrames = make([][]string, numFrames)
179 offset := 0
180 for i := range a.initialFrames {
181 a.initialFrames[i] = make([]string, a.width+labelGapWidth+a.labelWidth)
182 for j := range a.initialFrames[i] {
183 if j+offset >= len(ramp) {
184 continue // skip if we run out of colors
185 }
186
187 var c color.Color
188 if j <= a.cyclingCharWidth {
189 c = ramp[j+offset]
190 } else {
191 c = opts.LabelColor
192 }
193
194 // Also prerender the initial character with Lip Gloss to avoid
195 // processing in the render loop.
196 a.initialFrames[i][j] = lipgloss.NewStyle().
197 Foreground(c).
198 Render(string(initialChar))
199 }
200 if opts.CycleColors {
201 offset++
202 }
203 }
204
205 // Prerender scrambled rune frames for the animation.
206 a.cyclingFrames = make([][]string, numFrames)
207 offset = 0
208 for i := range a.cyclingFrames {
209 a.cyclingFrames[i] = make([]string, a.width)
210 for j := range a.cyclingFrames[i] {
211 if j+offset >= len(ramp) {
212 continue // skip if we run out of colors
213 }
214
215 // Also prerender the color with Lip Gloss here to avoid processing
216 // in the render loop.
217 r := availableRunes[rand.IntN(len(availableRunes))]
218 a.cyclingFrames[i][j] = lipgloss.NewStyle().
219 Foreground(ramp[j+offset]).
220 Render(string(r))
221 }
222 if opts.CycleColors {
223 offset++
224 }
225 }
226
227 // Cache the results
228 labelSlice := make([]string, a.label.Len())
229 for i, v := range a.label.Seq2() {
230 labelSlice[i] = v
231 }
232 ellipsisSlice := make([]string, a.ellipsisFrames.Len())
233 for i, v := range a.ellipsisFrames.Seq2() {
234 ellipsisSlice[i] = v
235 }
236 cached = &animCache{
237 initialFrames: a.initialFrames,
238 cyclingFrames: a.cyclingFrames,
239 width: a.width,
240 labelWidth: a.labelWidth,
241 label: labelSlice,
242 ellipsisFrames: ellipsisSlice,
243 }
244 animCacheMap.Set(cacheKey, cached)
245 }
246
247 // Random assign a birth to each character for a stagged entrance effect.
248 a.birthOffsets = make([]time.Duration, a.width)
249 for i := range a.birthOffsets {
250 a.birthOffsets[i] = time.Duration(rand.N(int64(maxBirthOffset))) * time.Nanosecond
251 }
252
253 return a
254}
255
256// SetLabel updates the label text and re-renders it.
257func (a *Anim) SetLabel(newLabel string) {
258 a.labelWidth = lipgloss.Width(newLabel)
259
260 // Update total width
261 a.width = a.cyclingCharWidth
262 if newLabel != "" {
263 a.width += labelGapWidth + a.labelWidth
264 }
265
266 // Re-render the label
267 a.renderLabel(newLabel)
268}
269
270// renderLabel renders the label with the current label color.
271func (a *Anim) renderLabel(label string) {
272 if a.labelWidth > 0 {
273 // Pre-render the label.
274 labelRunes := []rune(label)
275 a.label = csync.NewSlice[string]()
276 for i := range labelRunes {
277 rendered := lipgloss.NewStyle().
278 Foreground(a.labelColor).
279 Render(string(labelRunes[i]))
280 a.label.Append(rendered)
281 }
282
283 // Pre-render the ellipsis frames which come after the label.
284 a.ellipsisFrames = csync.NewSlice[string]()
285 for _, frame := range ellipsisFrames {
286 rendered := lipgloss.NewStyle().
287 Foreground(a.labelColor).
288 Render(frame)
289 a.ellipsisFrames.Append(rendered)
290 }
291 } else {
292 a.label = csync.NewSlice[string]()
293 a.ellipsisFrames = csync.NewSlice[string]()
294 }
295}
296
297// Width returns the total width of the animation.
298func (a *Anim) Width() (w int) {
299 w = a.width
300 if a.labelWidth > 0 {
301 w += labelGapWidth + a.labelWidth
302
303 var widestEllipsisFrame int
304 for _, f := range ellipsisFrames {
305 fw := lipgloss.Width(f)
306 if fw > widestEllipsisFrame {
307 widestEllipsisFrame = fw
308 }
309 }
310 w += widestEllipsisFrame
311 }
312 return w
313}
314
315// Init starts the animation.
316func (a *Anim) Init() tea.Cmd {
317 return a.Step()
318}
319
320// Update processes animation steps (or not).
321func (a *Anim) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
322 switch msg := msg.(type) {
323 case StepMsg:
324 if msg.id != a.id {
325 // Reject messages that are not for this instance.
326 return a, nil
327 }
328
329 step := a.step.Add(1)
330 if int(step) >= len(a.cyclingFrames) {
331 a.step.Store(0)
332 }
333
334 if a.initialized.Load() && a.labelWidth > 0 {
335 // Manage the ellipsis animation.
336 ellipsisStep := a.ellipsisStep.Add(1)
337 if int(ellipsisStep) >= ellipsisAnimSpeed*len(ellipsisFrames) {
338 a.ellipsisStep.Store(0)
339 }
340 } else if !a.initialized.Load() && time.Since(a.startTime) >= maxBirthOffset {
341 a.initialized.Store(true)
342 }
343 return a, a.Step()
344 default:
345 return a, nil
346 }
347}
348
349// View renders the current state of the animation.
350func (a *Anim) View() string {
351 var b strings.Builder
352 step := int(a.step.Load())
353 for i := range a.width {
354 switch {
355 case !a.initialized.Load() && i < len(a.birthOffsets) && time.Since(a.startTime) < a.birthOffsets[i]:
356 // Birth offset not reached: render initial character.
357 b.WriteString(a.initialFrames[step][i])
358 case i < a.cyclingCharWidth:
359 // Render a cycling character.
360 b.WriteString(a.cyclingFrames[step][i])
361 case i == a.cyclingCharWidth:
362 // Render label gap.
363 b.WriteString(labelGap)
364 case i > a.cyclingCharWidth:
365 // Label.
366 if labelChar, ok := a.label.Get(i - a.cyclingCharWidth - labelGapWidth); ok {
367 b.WriteString(labelChar)
368 }
369 }
370 }
371 // Render animated ellipsis at the end of the label if all characters
372 // have been initialized.
373 if a.initialized.Load() && a.labelWidth > 0 {
374 ellipsisStep := int(a.ellipsisStep.Load())
375 if ellipsisFrame, ok := a.ellipsisFrames.Get(ellipsisStep / ellipsisAnimSpeed); ok {
376 b.WriteString(ellipsisFrame)
377 }
378 }
379
380 return b.String()
381}
382
383// Step is a command that triggers the next step in the animation.
384func (a *Anim) Step() tea.Cmd {
385 return tea.Tick(time.Second/time.Duration(fps), func(t time.Time) tea.Msg {
386 return StepMsg{id: a.id}
387 })
388}
389
390// makeGradientRamp() returns a slice of colors blended between the given keys.
391// Blending is done as Hcl to stay in gamut.
392func makeGradientRamp(size int, stops ...color.Color) []color.Color {
393 if len(stops) < 2 {
394 return nil
395 }
396
397 points := make([]colorful.Color, len(stops))
398 for i, k := range stops {
399 points[i], _ = colorful.MakeColor(k)
400 }
401
402 numSegments := len(stops) - 1
403 if numSegments == 0 {
404 return nil
405 }
406 blended := make([]color.Color, 0, size)
407
408 // Calculate how many colors each segment should have.
409 segmentSizes := make([]int, numSegments)
410 baseSize := size / numSegments
411 remainder := size % numSegments
412
413 // Distribute the remainder across segments.
414 for i := range numSegments {
415 segmentSizes[i] = baseSize
416 if i < remainder {
417 segmentSizes[i]++
418 }
419 }
420
421 // Generate colors for each segment.
422 for i := range numSegments {
423 c1 := points[i]
424 c2 := points[i+1]
425 segmentSize := segmentSizes[i]
426
427 for j := range segmentSize {
428 if segmentSize == 0 {
429 continue
430 }
431 t := float64(j) / float64(segmentSize)
432 c := c1.BlendHcl(c2, t)
433 blended = append(blended, c)
434 }
435 }
436
437 return blended
438}
439
440func colorIsUnset(c color.Color) bool {
441 if c == nil {
442 return true
443 }
444 _, _, _, a := c.RGBA()
445 return a == 0
446}