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