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 "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}