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}