anim.go

  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}