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}