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